mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-30 07:26:11 +00:00
Compare commits
313 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b7c6b11909 | |||
| 74d489b51e | |||
| dff69b4d73 | |||
| 9c654983d1 | |||
| 0bc82fd719 | |||
| 783b390448 | |||
| 5c75155293 | |||
| 7588286787 | |||
| 0d6797275c | |||
| 17f98f5c56 | |||
| f53fdf86f6 | |||
| b57f9312ce | |||
| 5136d33c2c | |||
| 5589ca42c7 | |||
| 8f9cf6e153 | |||
| eebbc895c8 | |||
| 7d19a71230 | |||
| 77569e0dfb | |||
| 7a457b5d63 | |||
| 1ea366c419 | |||
| bba8a0d6b4 | |||
| 6abe450388 | |||
| fe193268ea | |||
| ef6cf8e3f4 | |||
| e06a687ddd | |||
| 1ee13de7a8 | |||
| 8cb061784e | |||
| 7fd4a4fe62 | |||
| 8929f1c5b4 | |||
| ff04137d0a | |||
| ff8bc7927f | |||
| 8a53c6233f | |||
| f3a4ce6ed8 | |||
| 42dae1b38c | |||
| b58830ddcb | |||
| caac165172 | |||
| d25f2286b4 | |||
| 1dd4a477c5 | |||
| b8312ee0c6 | |||
| d639d9dda9 | |||
| f44e8aadb0 | |||
| e5b7003799 | |||
| 6db3cb9741 | |||
| a213290eb6 | |||
| d4e67cdbb5 | |||
| e4d3e8ed30 | |||
| d4774ed043 | |||
| b9cc515258 | |||
| fef1f54c09 | |||
| 60dc850317 | |||
| da58ac0a27 | |||
| d1d15db4fe | |||
| a3810b74a7 | |||
| 80d50a6d92 | |||
| 5371de3856 | |||
| 483dc3de94 | |||
| 06b69aa0d8 | |||
| 351c8d4b3a | |||
| e704aa7359 | |||
| 622a2437a0 | |||
| 5ceab7e611 | |||
| 8a7cd5ed98 | |||
| 15804e2817 | |||
| 194958d3cd | |||
| 40323ca57e | |||
| 1b4a87d467 | |||
| a43a4e474a | |||
| 82dafc5e8f | |||
| b4af8c77be | |||
| 0bfab326e5 | |||
| 3a898b5071 | |||
| 9377671349 | |||
| 902560692b | |||
| 3946eddcea | |||
| 48e27fb2d6 | |||
| 41b33895c5 | |||
| 157273726d | |||
| 0d994773d8 | |||
| 7dd9f81c31 | |||
| 7d07cb7805 | |||
| 485a5b4604 | |||
| 2109133aee | |||
| 7ac7a5e8ba | |||
| 014b3e1e83 | |||
| b948d32c9a | |||
| 40d3922fe0 | |||
| 37bd4c73f5 | |||
| 9f4c1efe26 | |||
| 13a34fb7f1 | |||
| 60a41e9778 | |||
| 9aa5e46e22 | |||
| 3ba4a10031 | |||
| fd0ae98868 | |||
| 8101aa1e50 | |||
| cf3647da49 | |||
| cafb1e9a33 | |||
| d38265071d | |||
| 40d88ef1e0 | |||
| 14d1266b89 | |||
| 0730373e9b | |||
| 03f4470db6 | |||
| 697077c7e5 | |||
| befc101d8f | |||
| 7250baca02 | |||
| fa1809d910 | |||
| 9a718539fd | |||
| a1ba1fd1ab | |||
| 94399170d8 | |||
| 824bc64f96 | |||
| a7c3c16054 | |||
| 525be338b8 | |||
| 734ca2b8fc | |||
| 2a74395874 | |||
| 012eb8c057 | |||
| 0c1b83268c | |||
| 1088b36b8b | |||
| dfd1b86e01 | |||
| b8a663e11d | |||
| 38b499b80b | |||
| 9252b8081f | |||
| ba92811f45 | |||
| 0b813891e4 | |||
| dc4f6ed28b | |||
| 3564e5ba88 | |||
| de00471da9 | |||
| 9b3e9e3900 | |||
| 58e9f19f82 | |||
| 8e000f1849 | |||
| 77de8c92c6 | |||
| 17fc23f8cb | |||
| e046571350 | |||
| 6b5c55998f | |||
| 6df57459c2 | |||
| caeb7a3e58 | |||
| bf37fe46c1 | |||
| 3678995a9b | |||
| 7a917eeaee | |||
| d5a83374d0 | |||
| 596a9ffc2b | |||
| 0e1428922f | |||
| 10314cd8f2 | |||
| 9ee882ab68 | |||
| eefa365b6c | |||
| 21ca9eb068 | |||
| af86e8f7cc | |||
| 8c5fbd5739 | |||
| 55b667ad2c | |||
| 325632e780 | |||
| 9ad3cb2df1 | |||
| 127d5c1173 | |||
| 6df88abdb3 | |||
| 5c3d158d85 | |||
| 3bedc65ed5 | |||
| e5cb3c4f5e | |||
| d7b094852d | |||
| 516cdc71a7 | |||
| 4a822767d4 | |||
| f82eabd6eb | |||
| 1892da30e4 | |||
| fd44518e14 | |||
| d95640c597 | |||
| b5e9f45a46 | |||
| b4dabcce94 | |||
| 6bbdcc0313 | |||
| 283c094986 | |||
| 47728fbcd4 | |||
| d5f885c4b4 | |||
| dd4975b73e | |||
| 18310b05aa | |||
| 40894ed5e7 | |||
| 6894e71668 | |||
| a28fdc874f | |||
| 7480a2e218 | |||
| 96fc88462d | |||
| 6b86549fd0 | |||
| e8e350bf14 | |||
| 4e519fa4b7 | |||
| 52f6a177f9 | |||
| caafaeafd4 | |||
| bfb6c12384 | |||
| cd4199b1d1 | |||
| d203ceee32 | |||
| 59c012b42e | |||
| 86a122f31f | |||
| 14af0a05cd | |||
| ee41ea80cf | |||
| 20ae3d8c09 | |||
| d0f62a6aaf | |||
| 683142dd27 | |||
| e3f9ad5704 | |||
| 0f98d9ca17 | |||
| bfe345ca93 | |||
| 587ed74d96 | |||
| d5222fcb93 | |||
| 8d24c5680b | |||
| 818f5f1091 | |||
| 485bda3658 | |||
| 0b82faa7bd | |||
| c26542776e | |||
| 80cd6c52f4 | |||
| e40471e61a | |||
| 7908d54999 | |||
| 51aaa2e80a | |||
| 0a6779cd26 | |||
| 1a1c2b413e | |||
| 5e763375b4 | |||
| 9f4f456dc5 | |||
| af2f2c7abe | |||
| 98d87a8579 | |||
| 0709ce2f76 | |||
| 69d876b0a6 | |||
| 224eb668a1 | |||
| a6980a93f6 | |||
| eb57980b53 | |||
| c4c2415de8 | |||
| 9331366025 | |||
| 85a0cb3c31 | |||
| 5efa7d078c | |||
| 35d1445ebb | |||
| 23cd9050ea | |||
| 8615fb458f | |||
| 645f747191 | |||
| 5934ae9fc4 | |||
| 3d10011460 | |||
| edad842a0b | |||
| be239b74b8 | |||
| 4fee61bf68 | |||
| e8b8a8de0c | |||
| c287cb3eae | |||
| 182ea2e220 | |||
| a6e8fe320f | |||
| e5b69325a2 | |||
| ee1566ae20 | |||
| a675c70e2b | |||
| b45a7ae2ff | |||
| b2f6b7bd7f | |||
| bd064b1164 | |||
| a3f98abcb5 | |||
| fb4ba57a7a | |||
| 51200702c9 | |||
| d093be3e8d | |||
| 4868a4448d | |||
| 03a8bf92c9 | |||
| 7265052936 | |||
| 0c01f3ed76 | |||
| caa6952ae7 | |||
| 6bc503f670 | |||
| 87c5446cde | |||
| cb9b34a7aa | |||
| 2bb3dcd69e | |||
| 36aeec1b45 | |||
| 033781d083 | |||
| 8a733be369 | |||
| 91251ac42a | |||
| 2a03bebc58 | |||
| 2e6fa5ac74 | |||
| a61a50cd53 | |||
| 1271658b16 | |||
| 5485f75620 | |||
| 609fcbacaf | |||
| 7b7d35f428 | |||
| a5a1039d22 | |||
| 9d50ff8901 | |||
| 0ce59b700c | |||
| 309e15bd33 | |||
| 28da10e9fb | |||
| 36aafb655f | |||
| 24ea5c76c8 | |||
| f5117e2290 | |||
| 0cd8baa85d | |||
| cd009071db | |||
| f13b823387 | |||
| 2517e8eb28 | |||
| d378fabc01 | |||
| be0e37008a | |||
| 04fae4fe86 | |||
| 2b29019b59 | |||
| aa578def0e | |||
| 7eceed16e1 | |||
| 27454d5744 | |||
| 46b8503207 | |||
| 50bb09eb92 | |||
| aaf9b74426 | |||
| e31a5d2875 | |||
| 5dc17377f6 | |||
| e2e12055bf | |||
| 134ea71d63 | |||
| 2f9a404323 | |||
| 964a548e81 | |||
| 5d5a893557 | |||
| d4e4170813 | |||
| 6d313d2832 | |||
| 0618df3c56 | |||
| c6fda24843 | |||
| 32bfa8b5b7 | |||
| c7bfc1e2f1 | |||
| 2fb533ab92 | |||
| b0b38c1cfa | |||
| 34c8715c5a | |||
| 5e240d7b81 | |||
| a621a00252 | |||
| d1063f17cb | |||
| ed517b5b40 | |||
| 46058ffb90 | |||
| 81d0fe6ed3 | |||
| a26cd5edf7 | |||
| 48e9702fd2 | |||
| b560cb13b6 | |||
| b0f866dc7d | |||
| f3460ea828 | |||
| d88d4a143b | |||
| 913dfa13fe | |||
| 51345718c6 |
@@ -38,6 +38,8 @@
|
||||
- Simplicity first: minimal, targeted changes. No temporary fixes — find the root cause.
|
||||
- Where architecture is flawed, state is duplicated, or patterns are inconsistent, propose and implement the structural fix. Ask what a perfectionist reviewer would reject, and fix that too.
|
||||
- On non-trivial changes, ask whether there is a more elegant way before presenting. Skip it for simple fixes — don't over-engineer.
|
||||
- Reuse before you write: search for an existing helper by what it does (its key call, literal or regex), not by a guessed name. Web helpers live in `lib/` (route-only ones in a `lib/` beside the route), backend helpers in `Functions/Helpers/`.
|
||||
- Dead and copied code is gated: the .NET build fails on unused private members and parameters (`.editorconfig`), `npm run knip` covers web and MCP, and `npm run dup` in `tools/quality` fails on new clones. CI only reports, so run them before calling a change done.
|
||||
- English for code comments and everything that carries knowledge.
|
||||
|
||||
Private half of these instructions (process, working files, technical docs, operations): @../internal/CLAUDE.md
|
||||
|
||||
+1
-1
Submodule .claude/commands updated: d05399a386...0efa7c79ca
@@ -0,0 +1,11 @@
|
||||
# Analyzer severities for the .NET build. Directory.Build.props runs the IDE rules in
|
||||
# `dotnet build` (EnforceCodeStyleInBuild), and the warning freeze makes every warning an error.
|
||||
root = true
|
||||
|
||||
[*.cs]
|
||||
# Unused private members, private members that are written but never read, and unused parameters
|
||||
# of non-public methods. An injected dependency nobody reads is a feature that silently never runs.
|
||||
dotnet_diagnostic.IDE0051.severity = warning
|
||||
dotnet_diagnostic.IDE0052.severity = warning
|
||||
dotnet_diagnostic.IDE0060.severity = warning
|
||||
dotnet_code_quality_unused_parameters = non_public
|
||||
+18
-1
@@ -65,7 +65,7 @@ updates:
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
|
||||
# Validator tooling for rules/ (ajv-cli); combine-rules.yml installs it from this
|
||||
# Validator tooling for rules/ (ajv for validate.js); combine-rules.yml installs it from this
|
||||
# lockfile with contents:write in hand, so the pins must not go stale.
|
||||
- package-ecosystem: npm
|
||||
directory: /rules/scripts
|
||||
@@ -81,6 +81,23 @@ updates:
|
||||
applies-to: security-updates
|
||||
patterns: ['*']
|
||||
|
||||
# Duplicate-check tooling (jscpd for tools/quality/check-duplicates.mjs), installed by the
|
||||
# CI duplicates job from this lockfile. A jscpd update can change the clone fingerprints:
|
||||
# if its PR fails the job, regenerate the baseline (npm run dup:baseline) in that PR.
|
||||
- package-ecosystem: npm
|
||||
directory: /tools/quality
|
||||
schedule:
|
||||
interval: weekly
|
||||
cooldown:
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
quality-tooling:
|
||||
patterns: ['*']
|
||||
quality-tooling-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
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Fails when a workflow or a CI helper spells out a host instead of reading Get-Endpoints.ps1.
|
||||
|
||||
.DESCRIPTION
|
||||
The CI counterpart of the URL-registry guard tests (HardcodedUrlGuardTests,
|
||||
hardcodedUrls.guard.test.ts, hardcoded-urls.guard.test.ts). Enforced: every host the registry
|
||||
defines -- derived from it, so the list cannot fall behind -- plus any Azure storage, App Service
|
||||
or autopilotmonitor.com host, so a new or misspelled account is caught as well.
|
||||
|
||||
Scans .github/workflows/*.yml and .github/scripts/*.ps1 except the registry itself. Comment lines
|
||||
and PowerShell block comments do not count: comments may cite URLs.
|
||||
|
||||
.PARAMETER Root
|
||||
Repository root to scan. Defaults to the parent of .github/scripts.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Root
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if (-not $Root) {
|
||||
$Root = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
}
|
||||
|
||||
$endpoints = & (Join-Path $PSScriptRoot 'Get-Endpoints.ps1')
|
||||
$registryHosts = $endpoints.Values | ForEach-Object { ([uri]$_).Host } | Sort-Object -Unique
|
||||
$patterns = @($registryHosts | ForEach-Object { [regex]::Escape($_) }) + @(
|
||||
'[a-z0-9-]+\.(blob|table|queue|file)\.core\.windows\.net'
|
||||
'[a-z0-9-]+\.azurewebsites\.net'
|
||||
'([a-z0-9-]+\.)*autopilotmonitor\.com'
|
||||
)
|
||||
$hostPattern = [regex]::new('(' + ($patterns -join '|') + ')', 'IgnoreCase')
|
||||
|
||||
$files = @(
|
||||
Get-ChildItem -Path (Join-Path $Root '.github/workflows') -File -Filter '*.yml'
|
||||
Get-ChildItem -Path (Join-Path $Root '.github/workflows') -File -Filter '*.yaml'
|
||||
Get-ChildItem -Path (Join-Path $Root '.github/scripts') -File -Filter '*.ps1' |
|
||||
Where-Object Name -ne 'Get-Endpoints.ps1'
|
||||
)
|
||||
|
||||
$findings = 0
|
||||
foreach ($file in $files) {
|
||||
$relative = [System.IO.Path]::GetRelativePath($Root, $file.FullName) -replace '\\', '/'
|
||||
$isPowerShell = $file.Extension -eq '.ps1'
|
||||
$inBlock = $false
|
||||
$lineNumber = 0
|
||||
foreach ($line in [System.IO.File]::ReadAllLines($file.FullName)) {
|
||||
$lineNumber++
|
||||
$code = $line
|
||||
if ($isPowerShell) {
|
||||
if ($inBlock) {
|
||||
$end = $code.IndexOf('#>')
|
||||
if ($end -lt 0) { continue }
|
||||
$code = $code.Substring($end + 2)
|
||||
$inBlock = $false
|
||||
}
|
||||
$start = $code.IndexOf('<#')
|
||||
if ($start -ge 0) {
|
||||
$end = $code.IndexOf('#>', $start + 2)
|
||||
if ($end -lt 0) {
|
||||
$code = $code.Substring(0, $start)
|
||||
$inBlock = $true
|
||||
} else {
|
||||
$code = $code.Remove($start, $end + 2 - $start)
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($code.TrimStart().StartsWith('#')) { continue }
|
||||
|
||||
$match = $hostPattern.Match($code)
|
||||
if ($match.Success) {
|
||||
Write-Host "::error file=$relative,line=$lineNumber::$($match.Value) is spelled out here -- take it from .github/scripts/Get-Endpoints.ps1."
|
||||
$findings++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($findings -gt 0) {
|
||||
throw "$findings host literal(s) outside the endpoint registry."
|
||||
}
|
||||
Write-Host "No host literal outside .github/scripts/Get-Endpoints.ps1 ($($files.Count) files scanned)."
|
||||
@@ -0,0 +1,72 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
The one place the workflows and the release scripts take their hosts from.
|
||||
|
||||
.DESCRIPTION
|
||||
CI's member of the URL registry (Shared Constants.cs, web lib/config.ts, MCP src/config.ts):
|
||||
every own host the workflows and the .github/scripts helpers talk to, plus the Authenticode
|
||||
timestamp server, is defined here once. Assert-HostsFromRegistry.ps1 (Workflow gates) fails
|
||||
on any of these hosts spelled out elsewhere under .github/.
|
||||
|
||||
The storage URLs derive from the account name, so blob and table can never point at two
|
||||
different accounts.
|
||||
|
||||
A file in the repository rather than a GitHub variable: the helpers read it themselves, so
|
||||
they behave the same in every job and in a local run, and bearer tokens are sent to these
|
||||
URLs -- where a token goes stays a reviewed line in git.
|
||||
|
||||
.PARAMETER ExportToGitHubEnv
|
||||
Write every entry to $GITHUB_ENV instead of returning it, so the following steps of the job
|
||||
read them as environment variables ($env:AGENT_CONTAINER_URL, ${{ env.AGENT_CONTAINER_URL }}).
|
||||
|
||||
.OUTPUTS
|
||||
[ordered] hashtable, keyed by the names the environment variables carry.
|
||||
|
||||
.EXAMPLE
|
||||
$endpoints = & "$PSScriptRoot/Get-Endpoints.ps1"
|
||||
Invoke-WebRequest -Uri "$($endpoints.AGENT_CONTAINER_URL)/version.json"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$ExportToGitHubEnv
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$storageAccount = 'autopilotmonitoreu'
|
||||
# Pre-migration account in another tenant; only the fail-soft legacy mirror still writes there.
|
||||
$legacyStorageAccount = 'autopilotmonitor'
|
||||
|
||||
$blob = "https://$storageAccount.blob.core.windows.net"
|
||||
$table = "https://$storageAccount.table.core.windows.net"
|
||||
|
||||
$endpoints = [ordered]@{
|
||||
STORAGE_BLOB_URL = $blob
|
||||
STORAGE_TABLE_URL = $table
|
||||
AGENT_CONTAINER_URL = "$blob/agent"
|
||||
VERSIONS_CONTAINER_URL = "$blob/versions"
|
||||
STATS_CONTAINER_URL = "$blob/stats"
|
||||
# Platform settings row; CI writes only its agent and bootstrap version columns.
|
||||
ADMIN_CONFIG_URL = "$table/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
LEGACY_AGENT_CONTAINER_URL = "https://$legacyStorageAccount.blob.core.windows.net/agent"
|
||||
# Front Door in front of the agent container: what customer scripts download from.
|
||||
DOWNLOAD_ALIAS_URL = 'https://download.autopilotmonitor.com/agent'
|
||||
API_BASE_URL = 'https://autopilotmonitor-api-eu.azurewebsites.net'
|
||||
MCP_BASE_URL = 'https://mcp.autopilotmonitor.com'
|
||||
PORTAL_BASE_URL = 'https://portal.autopilotmonitor.com'
|
||||
WWW_BASE_URL = 'https://www.autopilotmonitor.com'
|
||||
DOCS_BASE_URL = 'https://docs.autopilotmonitor.com'
|
||||
CODE_SIGN_TIMESTAMP_URL = 'http://timestamp.acs.microsoft.com'
|
||||
}
|
||||
|
||||
if ($ExportToGitHubEnv) {
|
||||
if ([string]::IsNullOrWhiteSpace($env:GITHUB_ENV)) {
|
||||
throw '-ExportToGitHubEnv only works inside a GitHub Actions step.'
|
||||
}
|
||||
$endpoints.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" } |
|
||||
Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
$endpoints.GetEnumerator() | ForEach-Object { Write-Host "$($_.Key) = $($_.Value)" }
|
||||
return
|
||||
}
|
||||
|
||||
return $endpoints
|
||||
@@ -9,8 +9,8 @@
|
||||
live here so they cannot drift between call sites.
|
||||
|
||||
Identity comes from whatever `az login` established: the federated GitHub OIDC principal
|
||||
in CI (azure/login, subject repo:okieselbach/AutopilotMonitor:ref:refs/heads/main), the
|
||||
operator's own account for the local build. No secret is read, stored or passed around.
|
||||
in CI (azure/login in a job of the `production` or `release` environment), the operator's
|
||||
own account when a helper runs locally. No secret is read, stored or passed around.
|
||||
Under GitHub Actions the token is masked in the run log before it is returned.
|
||||
|
||||
x-ms-version is MANDATORY here, not decoration: Entra-authorized requests are rejected
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Runs the bootstrap integration tests once as SYSTEM, the account the bootstrap runs as on a device.
|
||||
|
||||
.DESCRIPTION
|
||||
Registers a one-time scheduled task as SYSTEM that runs
|
||||
scripts/Bootstrap/Bootstrap.Integration.Tests.ps1 under Windows PowerShell 5.1, waits for it,
|
||||
prints its output and fails when the run did not happen as SYSTEM or a test failed. Run as
|
||||
SYSTEM, the tests use the real folder permissions, including Administrators as owner.
|
||||
|
||||
CI only (bootstrap-script-gates.yml): it needs an elevated runner and Pester installed for all
|
||||
users.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path,
|
||||
[int]$TimeoutMinutes = 20
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$work = Join-Path ([System.IO.Path]::GetTempPath()) "apm-system-pass-$PID"
|
||||
New-Item -ItemType Directory -Force -Path $work | Out-Null
|
||||
$runner = Join-Path $work 'run.ps1'
|
||||
$output = Join-Path $work 'output.txt'
|
||||
$result = Join-Path $work 'result.txt'
|
||||
$tests = Join-Path $RepoRoot 'scripts\Bootstrap\Bootstrap.Integration.Tests.ps1'
|
||||
|
||||
$runnerText = @'
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value
|
||||
try {
|
||||
Import-Module Pester -MinimumVersion 5.5.0
|
||||
$configuration = New-PesterConfiguration
|
||||
$configuration.Run.Path = '__TESTS__'
|
||||
$configuration.Run.PassThru = $true
|
||||
$configuration.Output.Verbosity = 'Detailed'
|
||||
$run = Invoke-Pester -Configuration $configuration
|
||||
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|{1}|{2}' -f $identity, $run.PassedCount, $run.FailedCount)
|
||||
} catch {
|
||||
Set-Content -LiteralPath '__RESULT__' -Value ('{0}|error|{1}' -f $identity, $_.Exception.Message)
|
||||
}
|
||||
'@
|
||||
Set-Content -LiteralPath $runner -Encoding ASCII -Value ($runnerText.Replace('__TESTS__', $tests).Replace('__RESULT__', $result))
|
||||
|
||||
$taskName = "apm-bootstrap-system-pass-$PID"
|
||||
$arguments = "-NoProfile -ExecutionPolicy Bypass -Command `"& '$runner' *> '$output'`""
|
||||
$action = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument $arguments
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'S-1-5-18' -LogonType ServiceAccount -RunLevel Highest
|
||||
Register-ScheduledTask -TaskName $taskName -Action $action -Principal $principal -Force | Out-Null
|
||||
try {
|
||||
Start-ScheduledTask -TaskName $taskName
|
||||
$deadline = (Get-Date).AddMinutes($TimeoutMinutes)
|
||||
do {
|
||||
Start-Sleep -Seconds 5
|
||||
$state = (Get-ScheduledTask -TaskName $taskName).State
|
||||
} while ($state -eq 'Running' -and (Get-Date) -lt $deadline)
|
||||
if ($state -eq 'Running') {
|
||||
Stop-ScheduledTask -TaskName $taskName
|
||||
throw "The SYSTEM pass did not finish within $TimeoutMinutes minutes."
|
||||
}
|
||||
} finally {
|
||||
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false
|
||||
}
|
||||
|
||||
if (Test-Path -LiteralPath $output) { Get-Content -LiteralPath $output }
|
||||
if (-not (Test-Path -LiteralPath $result)) { throw 'The SYSTEM pass wrote no result.' }
|
||||
$identity, $passed, $failed = (Get-Content -LiteralPath $result -Raw).Trim().Split('|', 3)
|
||||
if ($identity -ne 'S-1-5-18') { throw "The SYSTEM pass ran as $identity, not as SYSTEM." }
|
||||
if ($passed -eq 'error') { throw "The SYSTEM pass could not run the tests: $failed" }
|
||||
if ([int]$failed -gt 0 -or [int]$passed -eq 0) { throw "SYSTEM pass: $passed passed, $failed failed." }
|
||||
Write-Host "SYSTEM pass: $passed passed, 0 failed."
|
||||
@@ -17,7 +17,7 @@
|
||||
cosmetics.
|
||||
|
||||
Steps, in order:
|
||||
1. Parse $ScriptVersion from the loader and the bootstrap source.
|
||||
1. Parse the $ScriptVersion line from the loader and the bootstrap source.
|
||||
2. Render the -Dev variants by literal substitution of the URL/manifest
|
||||
defaults. A missing anchor is a hard failure -- a silently un-substituted
|
||||
dev script would point the dev fleet at the stable agent.
|
||||
@@ -25,17 +25,21 @@
|
||||
carries the same $ScriptVersion, abort. Otherwise the docs badge,
|
||||
version.json.bootstrapVersion and the portal's "your script is outdated"
|
||||
hint would all keep asserting a version that is no longer what ships.
|
||||
4. Build the publish set (loader, bootstrap, both dev renders, the extras).
|
||||
4. Build the publish set: both installers, then both loaders, then the extras.
|
||||
A loader that went live before the installer it requires would refuse the
|
||||
installer still being served. -Channel dev keeps only the two -Dev renders.
|
||||
5. Authenticode-sign every script (see -SigningToken). Signing is the LAST
|
||||
transformation: it covers the exact CRLF/UTF-8 bytes that get uploaded,
|
||||
and nothing may touch them afterwards or the signature breaks.
|
||||
6. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
6. Run the loader's own identity check on the exact bytes of both installers:
|
||||
what fails here, every device would refuse.
|
||||
7. Upload every script blob with Cache-Control: no-cache (they rotate in
|
||||
place), mirrored fail-soft to the legacy account.
|
||||
7. Reconcile the version oracles: version.json.bootstrapVersion and
|
||||
.loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
8. Reconcile the version oracles (release channel only): version.json.bootstrapVersion
|
||||
and .loaderVersion (read-modify-write under If-Match, so a concurrent agent
|
||||
release cannot lose its agent fields) and
|
||||
AdminConfiguration.LatestBootstrapV2ScriptVersion.
|
||||
8. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
9. Verify through the alias -- re-download each blob and compare SHA-256.
|
||||
|
||||
Comparisons against a published script always strip its signature block first
|
||||
(Get-ComparableText). A signature carries a timestamp, so signed bytes differ on
|
||||
@@ -67,6 +71,11 @@
|
||||
Optional directory that receives a copy of every published file exactly as uploaded.
|
||||
Used by CI to offer the signed scripts as a build artifact for local inspection.
|
||||
|
||||
.PARAMETER Channel
|
||||
release (default): every script, plus version.json and the portal's script version.
|
||||
dev: only Start-AutopilotMonitor-Dev.ps1 and Install-AutopilotMonitor-Dev.ps1, so a
|
||||
script change can run on dev devices, signed, before it goes to customers.
|
||||
|
||||
.EXAMPLE
|
||||
./Publish-BootstrapScripts.ps1 -DryRun
|
||||
|
||||
@@ -81,9 +90,10 @@ param(
|
||||
[string]$SigningToken,
|
||||
[string]$KeyVaultUrl,
|
||||
[string]$CertificateName,
|
||||
[string]$TimestampUrl = 'http://timestamp.acs.microsoft.com',
|
||||
[string]$ExpectedPublisher,
|
||||
[string]$SignedOutputDir,
|
||||
[ValidateSet('release', 'dev')]
|
||||
[string]$Channel = 'release',
|
||||
[switch]$DryRun
|
||||
)
|
||||
|
||||
@@ -93,10 +103,13 @@ if (-not $RepoRoot) {
|
||||
$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
}
|
||||
|
||||
$ContainerUrl = 'https://autopilotmonitoreu.blob.core.windows.net/agent'
|
||||
$LegacyContainerUrl = 'https://autopilotmonitor.blob.core.windows.net/agent'
|
||||
$AliasUrl = 'https://download.autopilotmonitor.com/agent'
|
||||
$TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
# Every host, the timestamp server included, from the one endpoint registry.
|
||||
$Endpoints = & (Join-Path $PSScriptRoot 'Get-Endpoints.ps1')
|
||||
$ContainerUrl = $Endpoints.AGENT_CONTAINER_URL
|
||||
$LegacyContainerUrl = $Endpoints.LEGACY_AGENT_CONTAINER_URL
|
||||
$AliasUrl = $Endpoints.DOWNLOAD_ALIAS_URL
|
||||
$TableUrl = $Endpoints.ADMIN_CONFIG_URL
|
||||
$TimestampUrl = $Endpoints.CODE_SIGN_TIMESTAMP_URL
|
||||
|
||||
$BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1'
|
||||
$BootstrapBlob = 'Install-AutopilotMonitor.ps1'
|
||||
@@ -210,7 +223,7 @@ function Set-PublishSetSignature {
|
||||
--timestamp-rfc3161 $TimestampUrl `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor bootstrap' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
--description-url $Endpoints.WWW_BASE_URL `
|
||||
$paths
|
||||
|
||||
# Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing
|
||||
@@ -291,7 +304,9 @@ function Assert-VersionBump {
|
||||
|
||||
function Get-BootstrapScriptVersion {
|
||||
param([string]$Content, [string]$Origin, [switch]$Optional)
|
||||
if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] }
|
||||
# The assignment line itself, as the loader reads it: a comment or help text that mentions
|
||||
# $ScriptVersion must not count.
|
||||
if ($Content -match '(?m)^\$ScriptVersion[ \t]*=[ \t]*"([\d\.\-a-zA-Z]+)"[ \t]*\r?$') { return $Matches[1] }
|
||||
# Unreadable SOURCE is fatal. An unreadable PUBLISHED copy must not be: that is exactly
|
||||
# the state a republish is meant to repair, and a guard that blocks the repair is worse
|
||||
# than no guard.
|
||||
@@ -299,6 +314,19 @@ function Get-BootstrapScriptVersion {
|
||||
throw "Could not parse ScriptVersion from $Origin"
|
||||
}
|
||||
|
||||
# The loader's own check, run on the exact bytes that will be served: dot-sourced in this
|
||||
# function's scope, so none of the loader's variables reach the publisher's.
|
||||
function Assert-LoaderAcceptsInstaller {
|
||||
param([object]$Item, [string]$WorkDir)
|
||||
New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null
|
||||
$path = Join-Path $WorkDir $Item.BlobName
|
||||
[System.IO.File]::WriteAllBytes($path, [byte[]]$Item.Bytes)
|
||||
. $LoaderSource
|
||||
if (-not (Test-InstallerIdentity -Path $path)) {
|
||||
throw "The loader would refuse $($Item.BlobName) (role or minimum version). Nothing was uploaded."
|
||||
}
|
||||
}
|
||||
|
||||
# What the script actually DOES, with comments and layout removed: the PowerShell token
|
||||
# stream minus comments, newlines and line continuations. Lets the version-bump guard tell
|
||||
# a behaviour change (must be versioned -- consumers act on the version) apart from a
|
||||
@@ -327,8 +355,10 @@ $loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $Loa
|
||||
Write-Host "Loader script version: $loaderVersion"
|
||||
|
||||
# ------------------------------------------------------------------ 2. dev render
|
||||
$stableUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip"'
|
||||
$devUrlLiteral = '$AgentDownloadUrl = "https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip"'
|
||||
# The anchors are the customer scripts' own text, built from the alias in the registry. Should
|
||||
# the alias ever move, the scripts have to move with it -- until they do, the render stops here.
|
||||
$stableUrlLiteral = '$AgentDownloadUrl = "' + $AliasUrl + '/AutopilotMonitor-Agent.zip"'
|
||||
$devUrlLiteral = '$AgentDownloadUrl = "' + $AliasUrl + '/AutopilotMonitor-Agent-dev.zip"'
|
||||
$stableManLiteral = '$VersionJsonName = "version.json"'
|
||||
$devManLiteral = '$VersionJsonName = "version-dev.json"'
|
||||
|
||||
@@ -339,8 +369,8 @@ $devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace(
|
||||
Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)"
|
||||
|
||||
# The dev loader must fetch the dev bootstrap, or a dev device would run the stable chain.
|
||||
$stableLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"'
|
||||
$devLoaderLiteral = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1"'
|
||||
$stableLoaderLiteral = '$BootstrapUrl = "' + $AliasUrl + '/Install-AutopilotMonitor.ps1"'
|
||||
$devLoaderLiteral = '$BootstrapUrl = "' + $AliasUrl + '/Install-AutopilotMonitor-Dev.ps1"'
|
||||
|
||||
if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) {
|
||||
throw "Loader dev-render: anchor literal missing in $LoaderSource"
|
||||
@@ -349,8 +379,11 @@ $devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiter
|
||||
Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)"
|
||||
|
||||
# ------------------------------------------------------------------ 3. bump guards
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
# The versions are what customers act on, so the guard watches the release blobs only.
|
||||
if ($Channel -eq 'release') {
|
||||
Assert-VersionBump -BlobName $BootstrapBlob -SourceContent $sourceContent -SourceOrigin $BootstrapSource -SourceVersion $scriptVersion
|
||||
Assert-VersionBump -BlobName $LoaderBlob -SourceContent $loaderContent -SourceOrigin $LoaderSource -SourceVersion $loaderVersion
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 4. publish set
|
||||
# Sources beyond the bootstrap pair, read straight from the repo.
|
||||
@@ -365,10 +398,15 @@ $extraSources = @(
|
||||
# Text is the unsigned publish form and stays the reference for every comparison against a
|
||||
# published copy; Bytes is what goes on the wire and carries the signature after step 4.
|
||||
$publishSet = [System.Collections.Generic.List[object]]::new()
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderBlob; Text = $loaderContent; Bytes = (Get-PublishBytes $loaderContent) })
|
||||
$publishSet.Add([pscustomobject]@{ BlobName = $LoaderDevBlob; Text = $devLoaderContent; Bytes = (Get-PublishBytes $devLoaderContent) })
|
||||
if ($Channel -eq 'dev') {
|
||||
$publishSet = [System.Collections.Generic.List[object]]@($publishSet | Where-Object { $_.BlobName -in @($DevBlob, $LoaderDevBlob) })
|
||||
$extraSources = @()
|
||||
Write-Host "Channel dev: publishing $DevBlob and $LoaderDevBlob only"
|
||||
}
|
||||
foreach ($extra in $extraSources) {
|
||||
$extraPath = Join-Path $RepoRoot $extra.Path
|
||||
if (-not (Test-Path $extraPath)) {
|
||||
@@ -387,13 +425,18 @@ if ($SigningEnabled) {
|
||||
Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.'
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 6. loader check
|
||||
foreach ($item in @($publishSet | Where-Object { $_.BlobName -in @($BootstrapBlob, $DevBlob) })) {
|
||||
Assert-LoaderAcceptsInstaller -Item $item -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-check-$PID")
|
||||
}
|
||||
|
||||
$legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' }
|
||||
|
||||
# Acquired lazily: a dry run must stay runnable without any Azure login -- the PR gate
|
||||
# (bootstrap-script-gates.yml) executes this script with -DryRun and never logs in.
|
||||
$authHeaders = if ($DryRun) { $null } else { & (Join-Path $PSScriptRoot 'Get-StorageAuthHeaders.ps1') }
|
||||
|
||||
# ------------------------------------------------------------------ 6. upload
|
||||
# ------------------------------------------------------------------ 7. upload
|
||||
foreach ($item in $publishSet) {
|
||||
# Explicit type, not a convenience: an Object[] body is uploaded as space-separated
|
||||
# decimals rather than raw bytes, and every hash check still passes because [byte[]]
|
||||
@@ -460,11 +503,12 @@ foreach ($item in $publishSet) {
|
||||
}
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 7. version oracles
|
||||
# ------------------------------------------------------------------ 8. version oracles
|
||||
# version.json is the agent manifest; only bootstrapVersion belongs to us. Read from the
|
||||
# blob (not the alias) for an authoritative ETag, write back under If-Match so a concurrent
|
||||
# agent release cannot lose its version/sha256 fields.
|
||||
if (-not $DryRun) {
|
||||
# agent release cannot lose its version/sha256 fields. They describe the release scripts, so
|
||||
# a dev publish leaves them alone.
|
||||
if (-not $DryRun -and $Channel -eq 'release') {
|
||||
try {
|
||||
$manifestResp = Invoke-WebRequest -Uri "$ContainerUrl/version.json" -Method Get -UseBasicParsing
|
||||
$manifestRaw = if ($manifestResp.Content -is [byte[]]) {
|
||||
@@ -508,7 +552,9 @@ if (-not $DryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
if ($DryRun) {
|
||||
if ($Channel -ne 'release') {
|
||||
Write-Host "Channel dev: version.json and AdminConfiguration.LatestBootstrapV2ScriptVersion stay as they are"
|
||||
} elseif ($DryRun) {
|
||||
Write-Host " [dry-run] would set AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
} else {
|
||||
$tableHeaders = @{
|
||||
@@ -521,7 +567,7 @@ if ($DryRun) {
|
||||
Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion"
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------ 8. verify via alias
|
||||
# ------------------------------------------------------------------ 9. verify via alias
|
||||
if (-not $DryRun) {
|
||||
$pending = [System.Collections.ArrayList]::new()
|
||||
$publishSet | ForEach-Object { [void]$pending.Add($_) }
|
||||
@@ -551,7 +597,7 @@ if (-not $DryRun) {
|
||||
if ($env:GITHUB_STEP_SUMMARY) {
|
||||
$mode = if ($DryRun) { 'DRY RUN -- nothing uploaded' } else { 'published + verified via the download alias' }
|
||||
$summary = @(
|
||||
"### Bootstrap scripts: $mode",
|
||||
"### Bootstrap scripts: $mode (channel $Channel)",
|
||||
'',
|
||||
"Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**",
|
||||
"Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**",
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Checks the bootstrap's folders against a real standard user.
|
||||
|
||||
.DESCRIPTION
|
||||
Creates a temporary local standard user and, with that user's token, tries what any local user
|
||||
on an enrolling device could try against the folders the loader and the installer create:
|
||||
write, change, rename and delete files, add entries, move or delete the folder. Every attempt
|
||||
must be denied. Folders the user creates first, or entries the user plants in an unprotected
|
||||
agent folder of an older installer, must make the installer stop instead of installing into
|
||||
them. A control attempt proves the user can write where users may.
|
||||
|
||||
The folders come from the installer's own functions, in a scratch copy of ProgramData that
|
||||
carries the real ProgramData permissions.
|
||||
|
||||
CI only (bootstrap-script-gates.yml): it needs an elevated runner, and it creates a local
|
||||
account and removes it again.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# The user's token, for impersonation in this process (a CI helper; customer scripts never
|
||||
# compile code).
|
||||
Add-Type -Namespace ApmCi -Name Native -MemberDefinition @'
|
||||
[DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
|
||||
public static extern bool LogonUser(string user, string domain, string password, int logonType, int logonProvider, out Microsoft.Win32.SafeHandles.SafeAccessTokenHandle token);
|
||||
'@
|
||||
|
||||
. (Join-Path $RepoRoot 'scripts\Bootstrap\Install-AutopilotMonitor.ps1')
|
||||
|
||||
$results = New-Object System.Collections.Generic.List[object]
|
||||
|
||||
function Invoke-AsUser {
|
||||
param([scriptblock]$Action)
|
||||
[System.Security.Principal.WindowsIdentity]::RunImpersonated($script:token, [Action]$Action)
|
||||
}
|
||||
|
||||
# An attempt the user must not get through: it has to fail with "access denied", not with
|
||||
# any other error that would only hide a broken test.
|
||||
function Assert-Denied {
|
||||
param([string]$Name, [scriptblock]$Attempt)
|
||||
$outcome = 'allowed'
|
||||
try {
|
||||
Invoke-AsUser $Attempt
|
||||
} catch {
|
||||
$base = $_.Exception.GetBaseException()
|
||||
$outcome = if ($base -is [System.UnauthorizedAccessException] -or $base.HResult -eq -2147024891) { 'denied' } else { "error: $($base.Message)" }
|
||||
}
|
||||
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'denied'; Actual = $outcome })
|
||||
}
|
||||
|
||||
function Assert-That {
|
||||
param([string]$Name, [bool]$Condition, [string]$Detail = '')
|
||||
$results.Add([pscustomobject]@{ Check = $Name; Expected = 'true'; Actual = $(if ($Condition) { 'true' } else { "false $Detail".Trim() }) })
|
||||
}
|
||||
|
||||
# What a bootstrap running as SYSTEM leaves as owner; the default for other admin-created
|
||||
# entries differs between Windows editions.
|
||||
function Set-AdministratorsOwner {
|
||||
param([string]$Path)
|
||||
$security = (Get-Item -LiteralPath $Path -Force).GetAccessControl('Owner')
|
||||
$security.SetOwner((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544')))
|
||||
(Get-Item -LiteralPath $Path -Force).SetAccessControl($security)
|
||||
}
|
||||
|
||||
$name = 'apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8)
|
||||
$password = [guid]::NewGuid().ToString('N') + 'Aa1!'
|
||||
$root = Join-Path $env:SystemDrive ('apm-ci-' + [guid]::NewGuid().ToString('N').Substring(0, 8))
|
||||
$pd = Join-Path $root 'ProgramData'
|
||||
|
||||
New-LocalUser -Name $name -Password (ConvertTo-SecureString $password -AsPlainText -Force) -AccountNeverExpires -PasswordNeverExpires | Out-Null
|
||||
try {
|
||||
try { Add-LocalGroupMember -SID 'S-1-5-32-545' -Member $name } catch { if ($_.FullyQualifiedErrorId -notlike 'MemberExists*') { throw } }
|
||||
$token = $null
|
||||
# Interactive logon first (what a local user has); network logon where a server allows no
|
||||
# local logon for Users.
|
||||
if (-not [ApmCi.Native]::LogonUser($name, '.', $password, 2, 0, [ref]$token) -and
|
||||
-not [ApmCi.Native]::LogonUser($name, '.', $password, 3, 0, [ref]$token)) {
|
||||
throw "LogonUser failed with Win32 error $([System.Runtime.InteropServices.Marshal]::GetLastWin32Error())."
|
||||
}
|
||||
$script:token = $token
|
||||
$userSid = (New-Object System.Security.Principal.NTAccount($name)).Translate([System.Security.Principal.SecurityIdentifier])
|
||||
$script:probe = $null
|
||||
Invoke-AsUser { $script:probe = [System.Security.Principal.WindowsIdentity]::GetCurrent() }
|
||||
Assert-That 'impersonation runs as the test user' ($script:probe.User -eq $userSid) "$($script:probe.User)"
|
||||
Assert-That 'the test user is no administrator' (-not (New-Object System.Security.Principal.WindowsPrincipal($script:probe)).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator))
|
||||
|
||||
# A scratch ProgramData with the real one's permissions.
|
||||
New-Item -ItemType Directory -Path $pd -Force | Out-Null
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetSecurityDescriptorSddlForm((Get-Acl -LiteralPath $env:ProgramData).GetSecurityDescriptorSddlForm('Access'), 'Access')
|
||||
# Nothing from the drive root, whose default lets authenticated users change subfolders.
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
(Get-Item -LiteralPath $pd).SetAccessControl($security)
|
||||
|
||||
# Control: the user may create in ProgramData, so every denial below is the folders' doing.
|
||||
$control = Join-Path $pd 'user-may-create-here'
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($control) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $control 'x.txt'), 'x') }
|
||||
Assert-That 'control: the user can create in ProgramData' (Test-Path -LiteralPath (Join-Path $control 'x.txt'))
|
||||
|
||||
# The loader's run folder with a downloaded installer in it.
|
||||
$run = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
|
||||
$download = Join-Path $run 'Install-AutopilotMonitor.ps1'
|
||||
[System.IO.File]::WriteAllText($download, 'verified installer')
|
||||
Assert-Denied 'run folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $run 'planted.ps1'), 'x') }
|
||||
Assert-Denied 'run folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $run 'planted')) | Out-Null }
|
||||
Assert-Denied 'run folder: change the download' { [System.IO.File]::AppendAllText($download, 'tampered') }
|
||||
Assert-Denied 'run folder: replace the download' { [System.IO.File]::WriteAllText($download, 'replaced') }
|
||||
Assert-Denied 'run folder: rename the download' { [System.IO.File]::Move($download, $download + '.old') }
|
||||
Assert-Denied 'run folder: delete the download' { [System.IO.File]::Delete($download) }
|
||||
Assert-Denied 'run folder: rename the folder' { [System.IO.Directory]::Move($run, $run + '-moved') }
|
||||
Assert-Denied 'run folder: delete the folder' { [System.IO.Directory]::Delete($run, $true) }
|
||||
Assert-That 'run folder: the download is unchanged' ([System.IO.File]::ReadAllText($download) -eq 'verified installer')
|
||||
|
||||
# The agent folder an install prepares.
|
||||
$base = Join-Path $pd 'AutopilotMonitor'
|
||||
Initialize-AgentBase -Path $base
|
||||
$logs = [System.IO.Directory]::CreateDirectory((Join-Path $base 'Logs')).FullName
|
||||
[System.IO.File]::WriteAllText((Join-Path $logs 'bootstrap-script.log'), 'log')
|
||||
Assert-Denied 'agent folder: add a file' { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
Assert-Denied 'agent folder: add a folder or link' { [System.IO.Directory]::CreateDirectory((Join-Path $base 'Agent')) | Out-Null }
|
||||
Assert-Denied 'agent folder: change a log' { [System.IO.File]::AppendAllText((Join-Path $logs 'bootstrap-script.log'), 'x') }
|
||||
Assert-Denied 'agent folder: delete a log' { [System.IO.File]::Delete((Join-Path $logs 'bootstrap-script.log')) }
|
||||
Assert-Denied 'agent folder: rename the folder' { [System.IO.Directory]::Move($base, $base + '-moved') }
|
||||
$owner = (Get-Item -LiteralPath $base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value
|
||||
Assert-That 'agent folder: owned by Administrators' ($owner -eq 'S-1-5-32-544') $owner
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# The user creates the agent folder first: the installer must neither install into it nor
|
||||
# remove it.
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($base) | Out-Null; [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
Assert-That 'squatted agent folder: seen as another account''s' ((Get-FolderState $base) -eq 'Foreign')
|
||||
$refused = $false
|
||||
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*(Foreign); not installing into it.' }
|
||||
Assert-That 'squatted agent folder: the install refuses it' $refused
|
||||
Assert-That 'squatted agent folder: a skip leaves it' ((Remove-AgentBaseOnSkip -Path $base) -like '*left as it is (Foreign).')
|
||||
Assert-That 'squatted agent folder: the planted file stays' (Test-Path -LiteralPath (Join-Path $base 'planted.dll'))
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# An agent folder an older installer created without protection: the user plants a file,
|
||||
# the install protects the folder and stops at the planted file.
|
||||
[System.IO.Directory]::CreateDirectory($base) | Out-Null
|
||||
[System.IO.File]::WriteAllText((Join-Path $base 'old-installer.log'), 'old')
|
||||
Set-AdministratorsOwner $base
|
||||
Set-AdministratorsOwner (Join-Path $base 'old-installer.log')
|
||||
Invoke-AsUser { [System.IO.File]::WriteAllText((Join-Path $base 'planted.dll'), 'x') }
|
||||
$refused = $false
|
||||
try { Initialize-AgentBase -Path $base } catch { $refused = $_.Exception.Message -like '*holds entries another account created*' }
|
||||
Assert-That 'planted entry: the install stops' $refused
|
||||
Assert-That 'planted entry: own leftovers are gone, the planted file stays' ((-not (Test-Path -LiteralPath (Join-Path $base 'old-installer.log'))) -and (Test-Path -LiteralPath (Join-Path $base 'planted.dll')))
|
||||
Assert-Denied 'planted entry: the protected folder takes no more' { [System.IO.File]::WriteAllText((Join-Path $base 'second.dll'), 'x') }
|
||||
[System.IO.Directory]::Delete($base, $true)
|
||||
|
||||
# The user creates a run folder name first: it is never used.
|
||||
$squatted = Join-Path $pd 'AutopilotMonitor-Loader-00000000'
|
||||
Invoke-AsUser { [System.IO.Directory]::CreateDirectory($squatted) | Out-Null }
|
||||
$script:names = [System.Collections.Generic.Queue[string]]::new([string[]]@('00000000', '11111111'))
|
||||
function New-RunFolderName { return $script:names.Dequeue() }
|
||||
$fresh = New-RunFolder -Parent $pd -Prefix 'AutopilotMonitor-Loader-'
|
||||
Assert-That 'squatted run folder: a new name is drawn' ($fresh -eq (Join-Path $pd 'AutopilotMonitor-Loader-11111111')) $fresh
|
||||
$squattedOwner = (Get-Item -LiteralPath $squatted).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
Assert-That 'squatted run folder: still the user''s, untouched' ($squattedOwner -eq $userSid)
|
||||
} finally {
|
||||
if ($script:token) { $script:token.Dispose() }
|
||||
Remove-LocalUser -Name $name -ErrorAction SilentlyContinue
|
||||
if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
$results | Format-Table -AutoSize | Out-String -Width 200 | Write-Host
|
||||
$failed = @($results | Where-Object { $_.Actual -ne $_.Expected })
|
||||
if ($failed.Count -gt 0) { throw "Attacker test: $($failed.Count) of $($results.Count) checks failed." }
|
||||
Write-Host "Attacker test: all $($results.Count) checks passed."
|
||||
@@ -1,12 +1,15 @@
|
||||
name: Workflow gates
|
||||
|
||||
# Two checks over the workflow files themselves:
|
||||
# Three 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.
|
||||
# * 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).
|
||||
#
|
||||
# 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,
|
||||
@@ -26,15 +29,18 @@ name: Workflow gates
|
||||
#
|
||||
# The pins are not maintained by hand — .github/dependabot.yml opens a monthly grouped
|
||||
# PR that rewrites both the SHA and the trailing version comment. The comment is
|
||||
# therefore required too: it is what makes a pinned line reviewable.
|
||||
# therefore required too: it is what makes a pinned line reviewable. It names the exact
|
||||
# release tag (# v7.0.1), never the floating major (# v7): a major tag moves with every
|
||||
# upstream release, after which the comment no longer names the pinned commit and
|
||||
# zizmor's ref-version-mismatch flags every line that carries it.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths: ['.github/workflows/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
paths: ['.github/workflows/**', '.github/scripts/**', '.github/dependabot.yml', 'zizmor.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
@@ -45,7 +51,7 @@ jobs:
|
||||
name: Verify action pins
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -59,13 +65,15 @@ jobs:
|
||||
# nothing to pin.
|
||||
case "$ref" in ./*|\$/*) continue ;; esac
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*[^[:space:]] ]]; then
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+#[[:space:]]*v?[0-9]+\.[0-9]+\.[0-9]+([[:space:]]|$) ]]; then
|
||||
printf 'ok %s:%s %s\n' "$file" "$line" "$ref"
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]*$ ]]; then
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vN' so the line stays reviewable."
|
||||
echo "::error file=$file,line=$line::Pinned but the version comment is missing: '$ref'. Write 'owner/action@<sha> # vX.Y.Z' so the line stays reviewable."
|
||||
elif [[ "$ref" =~ ^[^@]+@[0-9a-f]{40}[[:space:]]+# ]]; then
|
||||
echo "::error file=$file,line=$line::The version comment must name the exact release tag the SHA resolves to ('# vX.Y.Z'), not a floating major: '$ref'. Find it with: gh api repos/<owner>/<action>/git/matching-refs/tags/vN"
|
||||
else
|
||||
echo "::error file=$file,line=$line::Action '$ref' is not pinned to a commit SHA. Tags are mutable -- resolve it with: gh api repos/<owner>/<action>/git/ref/tags/<tag>"
|
||||
fi
|
||||
@@ -89,7 +97,7 @@ jobs:
|
||||
security-events: write # SARIF upload: findings land in the Code scanning tab next to CodeQL
|
||||
actions: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -101,3 +109,17 @@ jobs:
|
||||
with:
|
||||
version: 1.30.1
|
||||
min-severity: low
|
||||
|
||||
hosts:
|
||||
name: Hosts only from the endpoint registry
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The enforced host list is derived from the registry itself, plus every Azure storage,
|
||||
# App Service and autopilotmonitor.com host, so it cannot fall behind a new entry.
|
||||
- name: No host literal outside .github/scripts/Get-Endpoints.ps1
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Assert-HostsFromRegistry.ps1
|
||||
|
||||
@@ -1,13 +1,50 @@
|
||||
name: Bootstrap script gates
|
||||
|
||||
# Quality gate for the customer-facing PowerShell scripts, factored out so it exists
|
||||
# once and both callers run the identical checks:
|
||||
# * pester.yml -- pull requests (reports, does not block the merge button)
|
||||
# * publish-scripts.yml -- pushes to main, where it BLOCKS the upload to the
|
||||
# download alias. Devices execute these scripts as SYSTEM
|
||||
# during enrollment, so a red suite must never ship.
|
||||
# Quality gate for the customer-facing PowerShell scripts. Devices execute them as SYSTEM
|
||||
# during enrollment, so a red suite must never ship. It runs in two roles:
|
||||
# * on its own, on every pull request and push to main that changes an input of these
|
||||
# checks (reports, does not block the merge button);
|
||||
# * called as the gates job that BLOCKS every signing or upload of bootstrap code:
|
||||
# publish-scripts.yml, build-agent.yml (stable releases), build-msi.yml. Those runs
|
||||
# show under the caller.
|
||||
#
|
||||
# Both path lists are the checks' complete inputs and stay identical. They cover every
|
||||
# push path of publish-scripts.yml, which leaves the check of a [skip publish] push to
|
||||
# this workflow.
|
||||
#
|
||||
# No concurrency group: it would also bind the runs called by the publishers, and a
|
||||
# cancel there would block a publish.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
# The integration tests run the generated OOBE script and the MSI's custom action; the
|
||||
# contract tests also read the MSI build.
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/scripts/Get-Endpoints.ps1'
|
||||
- '.github/scripts/Invoke-BootstrapSystemPass.ps1'
|
||||
- '.github/scripts/Test-BootstrapAttacker.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- 'src/Backend/AutopilotMonitor.Functions.Tests/TestData/oobe-bootstrap-golden.ps1'
|
||||
- 'src/Agent/AutopilotMonitor.BootstrapMsi/Package.wxs'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
@@ -15,21 +52,28 @@ permissions:
|
||||
|
||||
jobs:
|
||||
gates:
|
||||
name: Bootstrap guard tests (PS 5.1)
|
||||
runs-on: windows-latest
|
||||
name: Bootstrap guard tests (PS 5.1, ${{ matrix.os }})
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Folder permissions and Windows PowerShell 5.1 come with the OS, so both server images.
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# For all users: the SYSTEM pass below runs Pester as SYSTEM.
|
||||
- name: Install Pester 5+
|
||||
shell: powershell
|
||||
run: |
|
||||
Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser | Out-Null
|
||||
Install-Module Pester -Scope CurrentUser -Force -SkipPublisherCheck -MinimumVersion 5.5.0
|
||||
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
|
||||
|
||||
# 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.
|
||||
# The runner is elevated, so the integration tests use the real folder permissions.
|
||||
- name: Run Pester
|
||||
shell: powershell
|
||||
run: |
|
||||
@@ -40,11 +84,20 @@ jobs:
|
||||
$c.Output.Verbosity = 'Detailed'
|
||||
Invoke-Pester -Configuration $c
|
||||
|
||||
# On a device the bootstrap runs as SYSTEM: the integration tests once more as SYSTEM.
|
||||
- name: Integration tests as SYSTEM
|
||||
shell: powershell
|
||||
run: ./.github/scripts/Invoke-BootstrapSystemPass.ps1
|
||||
|
||||
- name: Folder permissions against a real standard user
|
||||
shell: powershell
|
||||
run: ./.github/scripts/Test-BootstrapAttacker.ps1
|
||||
|
||||
- name: Guard scripts stay pure ASCII
|
||||
shell: powershell
|
||||
run: |
|
||||
$failed = $false
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' | ForEach-Object {
|
||||
Get-ChildItem 'scripts/Bootstrap' -Filter '*.ps1' -Recurse | ForEach-Object {
|
||||
$bytes = [System.IO.File]::ReadAllBytes($_.FullName)
|
||||
for ($i = 0; $i -lt $bytes.Length; $i++) {
|
||||
if ($bytes[$i] -gt 127) {
|
||||
@@ -57,6 +110,15 @@ jobs:
|
||||
if ($failed) { exit 1 }
|
||||
Write-Host 'All bootstrap scripts are pure ASCII.'
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# The names and log lines the scripts share with the agent, checked against the agent's
|
||||
# own code.
|
||||
- name: Script contracts with the agent
|
||||
run: dotnet test src/Agent/AutopilotMonitor.Agent.V2.Core.Tests --configuration Release --filter "Category=BootstrapContract" --logger "console;verbosity=normal"
|
||||
|
||||
# The publish chain renders the -Dev variant by literal substitution and refuses to
|
||||
# ship when an anchor moved. Running it dry here surfaces that (and any drift against
|
||||
# the alias) on the PR instead of on the push that would have published.
|
||||
|
||||
@@ -7,6 +7,14 @@ on:
|
||||
description: 'Build number override (leave empty to auto-increment from buildcounter.txt)'
|
||||
required: false
|
||||
type: string
|
||||
channel:
|
||||
description: 'release = versioned line (+ stable on cutover). dev = lab channel only (Agent-v2-dev.zip, version-v2-dev.json, Agent-dev.zip, version-dev.json); needs publish_as_stable=false and create_release=false'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- dev
|
||||
default: release
|
||||
upload_blob:
|
||||
description: 'Upload to versioned namespace (Agent-v2.zip + version-v2.json)'
|
||||
required: true
|
||||
@@ -32,10 +40,11 @@ on:
|
||||
required: false
|
||||
type: string
|
||||
|
||||
# Every job states its own permissions. This default is what the build job runs with: it
|
||||
# executes third-party code (MSBuild targets, analyzers, test frameworks) and must never hold
|
||||
# more than a read-only token.
|
||||
permissions:
|
||||
contents: write
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
contents: read
|
||||
|
||||
# Shared with publish-scripts.yml: both write version.json (this workflow the agent fields,
|
||||
# the script publisher bootstrapVersion under If-Match). Serialising them keeps a script push
|
||||
@@ -48,7 +57,9 @@ env:
|
||||
LINE_MAJOR: 2
|
||||
AGENT_PROJECT: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
|
||||
DIALOG_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog/AutopilotMonitor.SummaryDialog.csproj
|
||||
TEST_PROJECT: src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj
|
||||
DECISIONCORE_TEST_PROJECT: src/Agent/AutopilotMonitor.DecisionCore.Tests/AutopilotMonitor.DecisionCore.Tests.csproj
|
||||
CORE_TEST_PROJECT: src/Agent/AutopilotMonitor.Agent.V2.Core.Tests/AutopilotMonitor.Agent.V2.Core.Tests.csproj
|
||||
DIALOG_TEST_PROJECT: src/Agent/AutopilotMonitor.SummaryDialog.Tests/AutopilotMonitor.SummaryDialog.Tests.csproj
|
||||
CSPROJ_PATH: src/Agent/AutopilotMonitor.Agent.V2/AutopilotMonitor.Agent.V2.csproj
|
||||
BOOTSTRAP_SOURCE: scripts/Bootstrap/Install-AutopilotMonitor.ps1
|
||||
AGENT_OUTPUT: src/Agent/AutopilotMonitor.Agent.V2/bin/Release/net48
|
||||
@@ -57,25 +68,49 @@ env:
|
||||
VERSIONED_ZIP: AutopilotMonitor-Agent-v2.zip
|
||||
STABLE_ZIP: AutopilotMonitor-Agent.zip
|
||||
STABLE_MANIFEST: version.json
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
|
||||
# Lab channel (channel=dev): the names the dev renders of the bootstrap scripts read.
|
||||
CHANNEL: ${{ inputs.channel }}
|
||||
DEV_VERSIONED_ZIP: AutopilotMonitor-Agent-v2-dev.zip
|
||||
DEV_MANIFEST: version-v2-dev.json
|
||||
DEV_STABLE_ZIP: AutopilotMonitor-Agent-dev.zip
|
||||
DEV_STABLE_MANIFEST: version-dev.json
|
||||
# Signed catalog over every other file of the payload. Must equal
|
||||
# Constants.AgentPayloadCatalogFileName; CodeSigningParityTests keeps them in step.
|
||||
PAYLOAD_CATALOG: AutopilotMonitor.Agent.cat
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
|
||||
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify step.
|
||||
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
|
||||
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
|
||||
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
name: Build Agent
|
||||
# Runs only this repository's scripts. The deploy identity trusts the `production`
|
||||
# environment, which admits main only.
|
||||
reserve:
|
||||
name: Reserve build number
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the counter write
|
||||
outputs:
|
||||
build_number: ${{ steps.version.outputs.build_number }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
tag: ${{ steps.version.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Every host in this workflow comes from the one endpoint registry.
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# Every released build is signed; a missing variable stops the run before a build
|
||||
# number is burnt.
|
||||
- name: Check code-signing configuration
|
||||
@@ -88,33 +123,22 @@ jobs:
|
||||
}
|
||||
Write-Host "Code-signing configuration present (certificate '$env:CODE_SIGN_CERTIFICATE_NAME')"
|
||||
|
||||
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account, so the storage login below has to be the last one. Both federated credentials
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
allow-no-subscriptions: true
|
||||
|
||||
- name: Acquire Key Vault token for signing
|
||||
# The dev channel is a lab channel: it never touches stable, AdminConfiguration, the
|
||||
# bootstrap scripts or a GitHub Release. Refused here, before a build number is burnt.
|
||||
- name: Check channel inputs
|
||||
shell: pwsh
|
||||
env:
|
||||
PUBLISH_AS_STABLE: ${{ inputs.publish_as_stable }}
|
||||
CREATE_RELEASE: ${{ inputs.create_release }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Could not acquire a Key Vault token for the code-signing identity."
|
||||
if ($env:CHANNEL -notin @('release', 'dev')) {
|
||||
throw "Unknown channel '$($env:CHANNEL)'."
|
||||
}
|
||||
Write-Host "::add-mask::$token"
|
||||
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
if ($env:CHANNEL -eq 'dev' -and ($env:PUBLISH_AS_STABLE -eq 'true' -or $env:CREATE_RELEASE -eq 'true')) {
|
||||
throw "channel=dev publishes the lab channel only: run it with publish_as_stable=false and create_release=false."
|
||||
}
|
||||
Write-Host "Channel: $env:CHANNEL"
|
||||
|
||||
# Every storage write below (counter blob, agent ZIP, manifests, AdminConfiguration)
|
||||
# is authorized by this federated identity -- no SAS secret. One login covers the whole
|
||||
# job: the federated credential is bound to the ref, and a release run takes ~2 minutes,
|
||||
# well inside the token lifetime. Only the legacy mirror still needs a SAS: that account
|
||||
# lives in the pre-migration tenant, where this principal has no identity.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -122,18 +146,6 @@ jobs:
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK above.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# Build numbers come from the shared counter blob -- one counter and one
|
||||
# implementation for CI and the local build (.github/scripts/Request-BuildNumber.ps1).
|
||||
# Reserve-before-build with ETag-CAS: concurrent builds can never mint the same
|
||||
@@ -155,7 +167,7 @@ jobs:
|
||||
$override = [int]$env:BUILD_NUMBER_OVERRIDE
|
||||
}
|
||||
|
||||
$counterUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$counterUrl = "$env:AGENT_CONTAINER_URL/buildcounter-v$($env:LINE_MAJOR).txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl $counterUrl -Override $override
|
||||
|
||||
$csproj = Get-Content $env:CSPROJ_PATH -Raw
|
||||
@@ -172,22 +184,177 @@ jobs:
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
"tag=v$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
|
||||
# release job treats that as data and never builds.
|
||||
build:
|
||||
name: Build and test
|
||||
needs: reserve
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# SDK 10 = single build toolchain (parity with local build.ps1 / global.json).
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: |
|
||||
dotnet restore ${{ env.AGENT_PROJECT }}
|
||||
dotnet restore ${{ env.DIALOG_PROJECT }}
|
||||
dotnet restore ${{ env.TEST_PROJECT }}
|
||||
dotnet restore ${{ env.DECISIONCORE_TEST_PROJECT }}
|
||||
dotnet restore ${{ env.CORE_TEST_PROJECT }}
|
||||
dotnet restore ${{ env.DIALOG_TEST_PROJECT }}
|
||||
|
||||
# Every agent suite runs before the release build. ORDER AND BUILD_NUMBER ARE LOAD-BEARING:
|
||||
# Agent.V2.Core.Tests references the agent exe, so the test build compiles it. With the
|
||||
# reserved number it stamps the release version and the Build Agent step is up to date; run
|
||||
# after that step or without the number, it would rewrite the binaries with another version.
|
||||
- name: Run agent tests (all suites)
|
||||
shell: pwsh
|
||||
env:
|
||||
BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }}
|
||||
run: |
|
||||
foreach ($project in @($env:DECISIONCORE_TEST_PROJECT, $env:CORE_TEST_PROJECT, $env:DIALOG_TEST_PROJECT)) {
|
||||
dotnet test $project --configuration Release --no-restore --verbosity quiet
|
||||
if ($LASTEXITCODE -ne 0) { throw "Tests failed: $project" }
|
||||
}
|
||||
|
||||
- name: Build Agent
|
||||
run: dotnet build ${{ env.AGENT_PROJECT }} --configuration Release --no-restore
|
||||
env:
|
||||
BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
|
||||
BUILD_NUMBER: ${{ needs.reserve.outputs.build_number }}
|
||||
|
||||
- name: Build SummaryDialog
|
||||
run: dotnet build ${{ env.DIALOG_PROJECT }} --configuration Release --no-restore
|
||||
|
||||
- name: Run Agent unit tests
|
||||
run: dotnet test ${{ env.TEST_PROJECT }} --configuration Release --no-restore --verbosity quiet
|
||||
# One archive per output folder, unsigned. ZipFile rather than Compress-Archive, which
|
||||
# skips hidden files.
|
||||
- name: Package build output
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$outDir = Join-Path $env:RUNNER_TEMP 'agent-unsigned'
|
||||
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
|
||||
[System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:AGENT_OUTPUT), (Join-Path $outDir 'agent.zip'))
|
||||
[System.IO.Compression.ZipFile]::CreateFromDirectory((Join-Path $env:GITHUB_WORKSPACE $env:DIALOG_OUTPUT), (Join-Path $outDir 'dialog.zip'))
|
||||
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
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# A stable release publishes the customer scripts as well, so it waits for the gates their
|
||||
# own publish waits for. Next to the build; skipped when nothing goes out as stable.
|
||||
gates:
|
||||
if: inputs.publish_as_stable
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool
|
||||
# run here -- never a restore or build. The build output arrives as data. The runner
|
||||
# image's Windows SDK signtool is used read-only, to verify third-party signatures.
|
||||
#
|
||||
# Runs in the `release` environment: both identities trust its subject, and it admits main
|
||||
# only. No federated credential trusts the ref, so a job without an environment can neither
|
||||
# sign nor write storage, whatever its permissions say.
|
||||
release:
|
||||
name: Sign and publish
|
||||
needs: [reserve, build, gates]
|
||||
# A skipped gates job (no stable publish) lets the release run; a failed or cancelled one stops it.
|
||||
if: ${{ !cancelled() && needs.build.result == 'success' && (needs.gates.result == 'success' || needs.gates.result == 'skipped') }}
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: write # tag + GitHub Release
|
||||
id-token: write # code-signing and storage logins, keyless Sigstore signing
|
||||
attestations: write # store the provenance attestation
|
||||
env:
|
||||
AGENT_VERSION: ${{ needs.reserve.outputs.version }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# Storage, legacy mirror, docs link, timestamp server: all from the endpoint registry.
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download unsigned build output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: agent-unsigned
|
||||
path: ${{ runner.temp }}/agent-unsigned
|
||||
|
||||
# Only the two expected archives, each into its own fresh output folder; the .NET
|
||||
# extractor refuses entries that would land outside it. Never unpack into the checkout
|
||||
# root: an archive could otherwise replace the scripts this job runs below.
|
||||
- name: Unpack build output
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
foreach ($pair in @(@('agent.zip', $env:AGENT_OUTPUT), @('dialog.zip', $env:DIALOG_OUTPUT))) {
|
||||
$archive = Join-Path $env:RUNNER_TEMP "agent-unsigned/$($pair[0])"
|
||||
$target = Join-Path $env:GITHUB_WORKSPACE $pair[1]
|
||||
if (Test-Path $target) { throw "$($pair[1]) already exists -- refusing to unpack over it." }
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory($archive, $target)
|
||||
Write-Host "Unpacked $($pair[0]) into $($pair[1])"
|
||||
}
|
||||
|
||||
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account, so the storage login below has to be the last one.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
allow-no-subscriptions: true
|
||||
|
||||
- name: Acquire Key Vault token for signing
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Could not acquire a Key Vault token for the code-signing identity."
|
||||
}
|
||||
Write-Host "::add-mask::$token"
|
||||
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# Every storage write below (agent ZIP, manifests, AdminConfiguration) is authorized by
|
||||
# this federated identity -- no SAS secret. Only the legacy mirror still needs a SAS:
|
||||
# that account lives in the pre-migration tenant, where this principal has no identity.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Runtime for AzureSignTool only; nothing is built in this job.
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK above.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# Authenticode over everything we author; third-party assemblies keep their own signature.
|
||||
#
|
||||
@@ -223,18 +390,17 @@ jobs:
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description "Autopilot Monitor Agent" `
|
||||
--description-url "https://www.autopilotmonitor.com" `
|
||||
--description-url "$env:WWW_BASE_URL" `
|
||||
($targets | ForEach-Object { $_.FullName })
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
- name: Stage release payload
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
$version = $env:AGENT_VERSION
|
||||
$stagingDir = "staging/agent"
|
||||
|
||||
if (-not (Test-Path "$env:AGENT_OUTPUT/AutopilotMonitor.Agent.exe")) {
|
||||
@@ -276,6 +442,98 @@ jobs:
|
||||
}
|
||||
Write-Host "Version check passed: EXE = manifest = reserved = $version"
|
||||
|
||||
# Third-party code enters the payload only with its vendor's own signature: every PE that
|
||||
# is not ours needs a valid EMBEDDED Authenticode signature from an allowed vendor. Embedded
|
||||
# only (signtool verify /pa, no /a): Get-AuthenticodeSignature prefers the runner's Windows
|
||||
# catalogs and reports "Microsoft Windows" for files Windows happens to ship as well. An
|
||||
# unsigned file or a new vendor stops the release until a person decides. This guards the
|
||||
# bytes we did not write; a build that alters our own code is D-346's remaining risk.
|
||||
- name: Check third-party signatures
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$allowedVendors = @('Microsoft Corporation', 'Json.NET (.NET Foundation)')
|
||||
|
||||
$signtool = Get-ChildItem "${env:ProgramFiles(x86)}\Windows Kits\10\bin\*\x64\signtool.exe" -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Directory.Parent.Name -match '^\d+(\.\d+){1,3}$' } |
|
||||
Sort-Object { [version]$_.Directory.Parent.Name } -Descending |
|
||||
Select-Object -First 1
|
||||
if (-not $signtool) { throw "signtool.exe not found in the runner image's Windows SDK." }
|
||||
Write-Host "Using $($signtool.FullName)"
|
||||
|
||||
$failed = $false
|
||||
$checked = 0
|
||||
foreach ($file in Get-ChildItem -Path 'staging/agent' -Recurse -File) {
|
||||
# A PE by content (MZ header), not by extension.
|
||||
$head = [byte[]]::new(2)
|
||||
$stream = [System.IO.File]::OpenRead($file.FullName)
|
||||
try { $read = $stream.Read($head, 0, 2) } finally { $stream.Dispose() }
|
||||
if ($read -lt 2 -or $head[0] -ne 0x4D -or $head[1] -ne 0x5A) { continue }
|
||||
if ($file.Name -like 'AutopilotMonitor.*') { continue } # ours: the verify step pins them
|
||||
|
||||
$checked++
|
||||
$output = & $signtool.FullName verify /pa $file.FullName 2>&1
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Host "::error file=$($file.Name)::No valid embedded vendor signature. $(($output | Out-String).Trim())"
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate]::CreateFromSignedFile($file.FullName).Subject
|
||||
$vendor = if ($subject -match '(?:^|,\s*)O=("[^"]*"|[^,]*)') { $Matches[1].Trim('"') } else { '' }
|
||||
if ($vendor -notin $allowedVendors) {
|
||||
Write-Host "::error file=$($file.Name)::Signed by '$subject' -- vendor '$vendor' is not on the allowlist."
|
||||
$failed = $true
|
||||
continue
|
||||
}
|
||||
Write-Host "$($file.Name): embedded signature valid, vendor $vendor"
|
||||
}
|
||||
|
||||
if ($failed) { throw "Third-party signature check failed. Nothing was published." }
|
||||
Write-Host "All $checked third-party PE files carry a valid signature from an allowed vendor."
|
||||
|
||||
# The payload contract: one catalog, signed like our binaries, holding the SHA-256 of every
|
||||
# other file in the ZIP. Installer, OOBE script and self-updater accept a payload only when
|
||||
# this catalog is ours and matches the extracted files exactly. Created AFTER signing and
|
||||
# BEFORE the ZIP -- the same load-bearing position as the signing step above.
|
||||
- name: Create and sign payload catalog
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$stagingDir = 'staging/agent'
|
||||
$catalog = Join-Path $env:RUNNER_TEMP $env:PAYLOAD_CATALOG
|
||||
if (Test-Path (Join-Path $stagingDir $env:PAYLOAD_CATALOG)) {
|
||||
throw "$stagingDir already contains $env:PAYLOAD_CATALOG."
|
||||
}
|
||||
|
||||
New-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -CatalogVersion 2.0 | Out-Null
|
||||
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description "Autopilot Monitor Agent" `
|
||||
--description-url "$env:WWW_BASE_URL" `
|
||||
$catalog
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "azuresigntool failed on the payload catalog with exit code $LASTEXITCODE -- aborting before anything is published."
|
||||
}
|
||||
|
||||
Copy-Item -Path $catalog -Destination $stagingDir
|
||||
$count = (Test-FileCatalog -Path $stagingDir -CatalogFilePath $catalog -FilesToSkip $env:PAYLOAD_CATALOG -Detailed).CatalogItems.Count
|
||||
Write-Host "Payload catalog signed: $count files"
|
||||
|
||||
- name: Create release artifact
|
||||
id: artifact
|
||||
shell: pwsh
|
||||
run: |
|
||||
$version = $env:AGENT_VERSION
|
||||
$stagingDir = "staging/agent"
|
||||
$manifest = Get-Content "$env:AGENT_OUTPUT/$env:MANIFEST_NAME" -Raw | ConvertFrom-Json
|
||||
|
||||
$versionedZip = "AutopilotMonitor-Agent-v$version.zip"
|
||||
Compress-Archive -Path "$stagingDir/*" -DestinationPath $versionedZip
|
||||
Write-Host "Created GitHub-Release artifact: $versionedZip"
|
||||
@@ -365,17 +623,66 @@ jobs:
|
||||
}
|
||||
Write-Host "All $($files.Count) binaries signed with certificate $($thumbprints.Keys -join ', ')"
|
||||
|
||||
# The devices' check, run on the FINAL ZIP rather than the staging folder: whatever the
|
||||
# archiver did (Compress-Archive skips hidden files) fails here instead of on a device.
|
||||
# Windows PowerShell 5.1 and the installer's own functions (dot-sourced; its entry guard
|
||||
# keeps that side-effect free) -- the very code the installer and the OOBE go-script run.
|
||||
- name: Verify payload catalog (device check)
|
||||
shell: powershell
|
||||
env:
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
|
||||
$payload = Join-Path $env:RUNNER_TEMP 'verify-payload'
|
||||
if (Test-Path $payload) { throw "$payload already exists." }
|
||||
[System.IO.Compression.ZipFile]::ExtractToDirectory((Resolve-Path $env:RELEASE_ZIP).Path, $payload)
|
||||
|
||||
. (Resolve-Path $env:BOOTSTRAP_SOURCE).Path
|
||||
if (-not [string]::IsNullOrWhiteSpace($env:CODE_SIGN_EXPECTED_SUBJECT) -and $ExpectedPublisher -ne $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "Installer pin '$ExpectedPublisher' differs from CODE_SIGN_EXPECTED_SUBJECT '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
if ($AgentCatalogName -ne $env:PAYLOAD_CATALOG) {
|
||||
throw "Installer catalog name '$AgentCatalogName' differs from PAYLOAD_CATALOG '$env:PAYLOAD_CATALOG'."
|
||||
}
|
||||
|
||||
# Assign first, then flatten: the function returns its list as ONE object (comma
|
||||
# operator), so @(Get-AgentPayloadRejections ...) would nest it inside another array.
|
||||
$problems = Get-AgentPayloadRejections -Path $payload -ExpectedPublisher $ExpectedPublisher -CatalogName $AgentCatalogName
|
||||
$problems = @($problems)
|
||||
|
||||
# Release-only checks a device need not repeat: a timestamp (without it the signature
|
||||
# dies with the certificate) and the same certificate as the binaries.
|
||||
$signature = Get-AuthenticodeSignature (Join-Path $payload $AgentCatalogName)
|
||||
if (-not $signature.TimeStamperCertificate) {
|
||||
$problems += 'catalog signature carries no RFC-3161 timestamp -- it would expire with the certificate'
|
||||
}
|
||||
$exeSigner = (Get-AuthenticodeSignature (Join-Path $payload 'AutopilotMonitor.Agent.exe')).SignerCertificate
|
||||
if ($signature.SignerCertificate -and $exeSigner -and $signature.SignerCertificate.Thumbprint -ne $exeSigner.Thumbprint) {
|
||||
$problems += 'catalog and agent exe carry different signing certificates'
|
||||
}
|
||||
|
||||
if ($problems.Count -gt 0) {
|
||||
$problems | ForEach-Object { Write-Host "::error::$_" }
|
||||
throw "Payload catalog verification failed. Nothing was published."
|
||||
}
|
||||
Write-Host "Payload catalog verified on the final ZIP with the installer's own check, signer $($signature.SignerCertificate.Subject)"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
env:
|
||||
ZIP_SHA256: ${{ steps.artifact.outputs.sha256 }}
|
||||
EXE_SHA256: ${{ steps.artifact.outputs.exe_sha256 }}
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$containerUrl = $env:AGENT_CONTAINER_URL
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
# Legacy mirror (transition until customers moved to the download.-alias):
|
||||
# blobs are ALSO pushed to the old account so the bootstrap URL baked into
|
||||
# customers' Intune scripts keeps serving current bits. Fail-soft -- a
|
||||
# legacy hiccup must never fail a release. Counter lives ONLY on eu.
|
||||
$legacyContainerUrl = "https://autopilotmonitor.blob.core.windows.net/agent"
|
||||
$legacyContainerUrl = $env:LEGACY_AGENT_CONTAINER_URL
|
||||
$legacySasToken = "${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}".TrimStart('?')
|
||||
|
||||
function Send-Blob {
|
||||
@@ -405,6 +712,18 @@ jobs:
|
||||
}
|
||||
}
|
||||
|
||||
# Dev channel: lab blobs only, under the names the dev renders of the bootstrap scripts
|
||||
# read. Nothing else is touched -- no versioned or stable line, no AdminConfiguration, no
|
||||
# scripts; the channel check in `reserve` already refused publish_as_stable/create_release.
|
||||
if ($env:CHANNEL -eq 'dev') {
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_VERSIONED_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_MANIFEST -ContentType "application/json"
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:DEV_STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:DEV_STABLE_MANIFEST -ContentType "application/json"
|
||||
Write-Host "DEV CHANNEL: lab blobs written, nothing else touched."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Versioned namespace (always)
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:VERSIONED_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:MANIFEST_NAME -ContentType "application/json"
|
||||
@@ -415,9 +734,9 @@ jobs:
|
||||
Send-Blob -Local $env:VERSIONED_ZIP -BlobName $env:STABLE_ZIP -ContentType "application/zip"
|
||||
Send-Blob -Local $env:MANIFEST_NAME -BlobName $env:STABLE_MANIFEST -ContentType "application/json"
|
||||
|
||||
# Customer-facing scripts: same publisher publish-scripts.yml runs on every push
|
||||
# to main, so a release lands a set that is consistent with what is already live
|
||||
# (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Customer-facing scripts: same publisher and same gates (the gates job) as
|
||||
# publish-scripts.yml, so a release lands a set that is consistent with what is
|
||||
# already live (dev render, version-bump guard, bootstrapVersion oracles, alias verification).
|
||||
# Runs AFTER version.json so its bootstrapVersion read sees this build's manifest.
|
||||
# Signed with the same identity: the device-side loader verifies the publisher
|
||||
# of what it downloads, and the publisher refuses to upload unsigned scripts.
|
||||
@@ -426,7 +745,6 @@ jobs:
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
|
||||
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
|
||||
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT
|
||||
}
|
||||
|
||||
@@ -437,10 +755,10 @@ jobs:
|
||||
if ('${{ inputs.publish_as_stable }}' -ne 'true') {
|
||||
Write-Host "SKIPPED: AdminConfiguration update (versioned-only run -- stable namespace unchanged)"
|
||||
} else {
|
||||
$sha256 = "${{ steps.artifact.outputs.sha256 }}"
|
||||
$exeSha256 = "${{ steps.artifact.outputs.exe_sha256 }}"
|
||||
$agentVersion = "${{ steps.version.outputs.version }}"
|
||||
$tableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminConfiguration(PartitionKey='GlobalConfig',RowKey='config')"
|
||||
$sha256 = $env:ZIP_SHA256
|
||||
$exeSha256 = $env:EXE_SHA256
|
||||
$agentVersion = $env:AGENT_VERSION
|
||||
$tableUrl = $env:ADMIN_CONFIG_URL
|
||||
$tableBody = @{
|
||||
LatestAgentV2Version = $agentVersion
|
||||
LatestAgentV2Sha256 = $sha256
|
||||
@@ -464,7 +782,7 @@ jobs:
|
||||
# Skipped on runs that publish nothing -- no log entry for bytes nobody receives.
|
||||
- name: Attest build provenance
|
||||
if: inputs.upload_blob || inputs.create_release
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: |
|
||||
${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
@@ -472,13 +790,13 @@ jobs:
|
||||
staging/agent/AutopilotMonitor.SummaryDialog.exe
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}
|
||||
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
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: version-metadata
|
||||
path: ${{ env.MANIFEST_NAME }}
|
||||
@@ -497,30 +815,30 @@ jobs:
|
||||
shell: pwsh
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ steps.version.outputs.tag }}
|
||||
RELEASE_NAME: Autopilot Monitor Agent ${{ steps.version.outputs.version }}
|
||||
RELEASE_TAG: ${{ needs.reserve.outputs.tag }}
|
||||
RELEASE_NAME: Autopilot Monitor Agent ${{ needs.reserve.outputs.version }}
|
||||
RELEASE_PRERELEASE: ${{ inputs.prerelease }}
|
||||
RELEASE_ZIP: ${{ steps.artifact.outputs.versioned_zip_release }}
|
||||
RELEASE_NOTES: ${{ inputs.release_notes }}
|
||||
RELEASE_BODY_TAIL: |
|
||||
### Version Info
|
||||
- **Version:** ${{ steps.version.outputs.version }}
|
||||
- **Build Number:** ${{ steps.version.outputs.build_number }}
|
||||
- **Version:** ${{ needs.reserve.outputs.version }}
|
||||
- **Build Number:** ${{ needs.reserve.outputs.build_number }}
|
||||
- **SHA-256:** `${{ steps.artifact.outputs.sha256 }}`
|
||||
- **Built from:** ${{ github.sha }}
|
||||
- **Published as stable:** ${{ inputs.publish_as_stable }}
|
||||
|
||||
### Provenance
|
||||
The executables are Authenticode-signed and timestamped. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
The executables are Authenticode-signed and timestamped. Every file in the ZIP is listed in `AutopilotMonitor.Agent.cat`, a catalog signed with the same certificate, and third-party libraries carry their vendor's signature. The ZIP and both executables carry a [GitHub Artifact Attestation](https://github.com/okieselbach/AutopilotMonitor/attestations) (Sigstore keyless). Verify:
|
||||
```
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ steps.version.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
gh attestation verify AutopilotMonitor-Agent-v${{ needs.reserve.outputs.version }}.zip --repo okieselbach/AutopilotMonitor
|
||||
```
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
$notes = "$env:RELEASE_NOTES".Trim()
|
||||
if ($notes -eq '') {
|
||||
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog](https://docs.autopilotmonitor.com/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
|
||||
$notes = "Minor enhancements and bug fixes. See the [Agent Changelog]($env:DOCS_BASE_URL/changelog/agent-changelog) for details.`n<!-- release-notes: none -->"
|
||||
}
|
||||
|
||||
# Previous release for the compare link. Fail-soft: the first release has none,
|
||||
|
||||
+282
-85
@@ -11,55 +11,291 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
upload_blob:
|
||||
description: 'Upload to blob (customer-facing via download.autopilotmonitor.com/agent/)'
|
||||
description: 'Upload to blob (customer-facing through the download alias)'
|
||||
required: true
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
# Every job states its own permissions. This default is what the build job runs with: wix is
|
||||
# third-party code and must never hold more than a read-only token.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
env:
|
||||
MSI_PROJECT_DIR: src/Agent/AutopilotMonitor.BootstrapMsi
|
||||
RUNNER_SCRIPT: scripts/Bootstrap/Start-AutopilotMonitor.ps1
|
||||
STABLE_MSI: AutopilotMonitor-Bootstrap.msi
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables.
|
||||
# Authenticode via Azure Key Vault; identity and vault come from repository variables, the
|
||||
# timestamp server (CODE_SIGN_TIMESTAMP_URL) from the endpoint registry like every host here.
|
||||
# CODE_SIGN_EXPECTED_SUBJECT is optional and pins the publisher in the verify steps.
|
||||
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
|
||||
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
|
||||
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# The loader executes as SYSTEM on every enrolling device, and the MSI's custom action runs
|
||||
# it: nothing is signed before the bootstrap gates pass on this commit, since a signed copy
|
||||
# of ours is trusted wherever it lands.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
# Signs the loader BEFORE the build: wix embeds the file as it finds it, so a loader signed
|
||||
# afterwards would leave the copy inside the MSI unsigned. Devices under an AllSigned policy
|
||||
# need the embedded loader to carry a signature of its own.
|
||||
#
|
||||
# Trust zone: only this repository's scripts, pinned actions and the pinned signing tool run
|
||||
# here. The `release` environment is the only subject the code-signing identity trusts.
|
||||
sign-loader:
|
||||
name: Sign bootstrap loader
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
name: Build Bootstrap MSI
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # code-signing login
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
# The loader executes as SYSTEM on every enrolling device — a parse error or
|
||||
# non-ASCII mojibake (PS 5.1 codepage roulette) must fail the build, not the fleet.
|
||||
# bootstrap-script-gates.yml runs the same checks plus Pester on pushes; this job
|
||||
# stands on its own because it can be dispatched at any time.
|
||||
- name: Validate runner script (parse + ASCII-only)
|
||||
# Every host in this workflow comes from the one endpoint registry.
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- name: Check code-signing configuration
|
||||
shell: pwsh
|
||||
run: |
|
||||
$errs = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseFile((Resolve-Path $env:RUNNER_SCRIPT), [ref]$null, [ref]$errs) | Out-Null
|
||||
if ($errs) { $errs | ForEach-Object { Write-Error $_.ToString() }; exit 1 }
|
||||
if ((Get-Content $env:RUNNER_SCRIPT -Raw) -match '[^\x00-\x7F]') {
|
||||
Write-Error "Runner script contains non-ASCII characters"
|
||||
exit 1
|
||||
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
|
||||
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
|
||||
}
|
||||
}
|
||||
Write-Host "Runner script parse + ASCII check passed"
|
||||
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ env.CODE_SIGN_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
allow-no-subscriptions: true
|
||||
|
||||
- name: Acquire Key Vault token for signing
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$token = az account get-access-token --scope https://vault.azure.net/.default --query accessToken --output tsv
|
||||
if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($token)) {
|
||||
throw "Could not acquire a Key Vault token for the code-signing identity."
|
||||
}
|
||||
Write-Host "::add-mask::$token"
|
||||
"CODE_SIGN_AKV_ACCESS_TOKEN=$token" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# A missing timestamp fails the run: without it the signature dies with the certificate.
|
||||
- name: Sign bootstrap loader (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor bootstrap runner' `
|
||||
--description-url $env:WWW_BASE_URL `
|
||||
$env:RUNNER_SCRIPT
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
$sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT
|
||||
if ($sig.Status -ne 'Valid') { throw "Signature on $env:RUNNER_SCRIPT is '$($sig.Status)', expected 'Valid'." }
|
||||
if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:RUNNER_SCRIPT carries no timestamp." }
|
||||
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "$env:RUNNER_SCRIPT was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
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
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ env.RUNNER_SCRIPT }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
|
||||
# release job treats that as data and never builds.
|
||||
build:
|
||||
name: Build MSI
|
||||
needs: sign-loader
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download signed loader
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bootstrap-loader-signed
|
||||
path: ${{ runner.temp }}/loader
|
||||
|
||||
# The signed copy replaces the checkout's unsigned one before wix embeds it.
|
||||
- name: Put the signed loader in place
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Copy-Item -Path (Join-Path $env:RUNNER_TEMP 'loader/Start-AutopilotMonitor.ps1') -Destination $env:RUNNER_SCRIPT -Force
|
||||
$sig = Get-AuthenticodeSignature -FilePath $env:RUNNER_SCRIPT
|
||||
if ($sig.Status -ne 'Valid') { throw "Loader to embed is '$($sig.Status)', expected 'Valid'." }
|
||||
Write-Host "Embedding the signed loader ($((Get-Item $env:RUNNER_SCRIPT).Length) bytes)"
|
||||
|
||||
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
|
||||
# install, so a compromised or simply broken point release must not flow into a build
|
||||
# unnoticed. Bumping is a reviewed commit.
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# The MSI's action runs only the exact bytes the package embeds: the hash of the
|
||||
# signed loader goes into the command here, so no other signed script of ours runs
|
||||
# in its place on a device.
|
||||
$loaderSha256 = (Get-FileHash -LiteralPath $env:RUNNER_SCRIPT -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Write-Host "Embedded loader SHA-256: $loaderSha256"
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -d LoaderSha256=$loaderSha256 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
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
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# The real chain on a runner, before anything is signed or published: msiexec installs the
|
||||
# MSI from the build, its action checks the loader copy and runs it, the loader fetches the
|
||||
# published installer, which finds the deployment marker set here and removes the copy. The
|
||||
# marker is the installer's first guard, so no agent is ever installed on the runner. Needs
|
||||
# the published installer to be 2.9 or later, which removes the copy in every outcome.
|
||||
smoke:
|
||||
name: Install on a runner (${{ matrix.os }})
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-2022, windows-2025]
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
|
||||
- name: Install with the deployment marker set, check the footprint, uninstall
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$msi = Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI"
|
||||
$wxs = Get-Content "$env:MSI_PROJECT_DIR/Package.wxs" -Raw
|
||||
if ($wxs -notmatch 'Version="(\d+\.\d+\.\d+)"') { throw 'Could not parse Version from Package.wxs' }
|
||||
$version = $Matches[1]
|
||||
|
||||
New-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Force | Out-Null
|
||||
Set-ItemProperty -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Name 'Deployed' -Value ((Get-Date).ToUniversalTime().ToString('o'))
|
||||
|
||||
function Invoke-Msiexec {
|
||||
param([string]$Verb, [string]$Log)
|
||||
$p = Start-Process -FilePath msiexec.exe -ArgumentList "/$Verb `"$msi`" /qn /l*v `"$Log`"" -Wait -PassThru
|
||||
if ($p.ExitCode -ne 0) { Get-Content $Log | Select-Object -Last 60; throw "msiexec /$Verb exited with $($p.ExitCode)" }
|
||||
}
|
||||
function Get-Registration {
|
||||
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall' | Get-ItemProperty | Where-Object { $_.DisplayName -eq 'Autopilot Monitor Bootstrap' }
|
||||
}
|
||||
function Show-Tree {
|
||||
param([string]$Path)
|
||||
Get-ChildItem -LiteralPath $Path -Recurse -Force | ForEach-Object { Write-Host " $($_.FullName)" }
|
||||
}
|
||||
|
||||
$installLog = Join-Path $env:RUNNER_TEMP 'msi-install.log'
|
||||
Invoke-Msiexec -Verb i -Log $installLog
|
||||
$registered = Get-Registration
|
||||
if (-not $registered) { throw 'The product is not registered after the install.' }
|
||||
if ($registered.DisplayVersion -ne $version) { throw "Registered version $($registered.DisplayVersion), Package.wxs says $version." }
|
||||
if ((Get-Content $installLog -Raw) -notmatch 'RunBootstrap') { throw 'The install log does not show the action.' }
|
||||
Write-Host "Registered: $($registered.DisplayName) $($registered.DisplayVersion)"
|
||||
|
||||
# Only the installer removes the copy the MSI put under ProgramData: its absence
|
||||
# proves the action ran the loader and the loader ran the installer.
|
||||
foreach ($path in @("$env:ProgramW6432\AutopilotMonitor", "$env:ProgramData\AutopilotMonitor")) {
|
||||
if (Test-Path -LiteralPath $path) { Show-Tree $path; throw "$path exists after the run." }
|
||||
}
|
||||
$runFolders = @(Get-ChildItem -LiteralPath $env:ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*' -Force)
|
||||
if ($runFolders.Count -ne 0) {
|
||||
foreach ($folder in $runFolders) {
|
||||
Show-Tree $folder.FullName
|
||||
Get-ChildItem -LiteralPath $folder.FullName -Filter '*.log' | ForEach-Object { Get-Content -LiteralPath $_.FullName }
|
||||
}
|
||||
throw 'A loader run folder stays: the loader did not end with exit 0.'
|
||||
}
|
||||
Write-Host 'After the install: nothing under ProgramData or Program Files.'
|
||||
|
||||
Invoke-Msiexec -Verb x -Log (Join-Path $env:RUNNER_TEMP 'msi-uninstall.log')
|
||||
if (Get-Registration) { throw 'The product is still registered after the uninstall.' }
|
||||
Remove-Item -Path 'HKLM:\SOFTWARE\AutopilotMonitor' -Recurse -Force
|
||||
Write-Host 'Uninstalled; the registration is gone.'
|
||||
|
||||
# Trust zone: signs, verifies and publishes the MSI as data; never builds. Environment
|
||||
# `release`, like sign-loader.
|
||||
release:
|
||||
name: Sign and publish MSI
|
||||
needs: smoke
|
||||
runs-on: windows-latest
|
||||
environment: release
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # code-signing and storage logins, keyless Sigstore signing
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# Read from this job's own checkout, never handed over by the build job.
|
||||
- name: Read MSI version from Package.wxs
|
||||
id: version
|
||||
shell: pwsh
|
||||
@@ -73,20 +309,19 @@ jobs:
|
||||
Write-Host "MSI version (from Package.wxs): $version"
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Every published MSI is signed; a missing variable stops the run before the build.
|
||||
- name: Check code-signing configuration
|
||||
- name: Download unsigned MSI
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: bootstrap-msi-unsigned
|
||||
path: ${{ runner.temp }}/msi
|
||||
|
||||
# Only the expected file leaves the download folder.
|
||||
- name: Take the MSI from the build
|
||||
shell: pwsh
|
||||
run: |
|
||||
foreach ($name in 'CODE_SIGN_CLIENT_ID', 'CODE_SIGN_CERTIFICATE_NAME', 'CODE_SIGN_KEY_VAULT_URL') {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($name))) {
|
||||
throw "Repository variable $name is not set. Every published MSI must be Authenticode-signed."
|
||||
}
|
||||
}
|
||||
run: Copy-Item -Path (Join-Path $env:RUNNER_TEMP "msi/$env:STABLE_MSI") -Destination $env:STABLE_MSI -ErrorAction Stop
|
||||
|
||||
# Logs in FIRST and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account, so the storage login below has to be the last one. Both federated credentials
|
||||
# are bound to the ref, so this job must never be given an `environment:` -- that would
|
||||
# change the OIDC subject claim and break both logins.
|
||||
# account, so the storage login below has to be the last one.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -113,50 +348,12 @@ jobs:
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Exact version, not a "5.*" range: wix writes the bytes of the MSI that customers
|
||||
# install, so a compromised or simply broken point release must not flow into a build
|
||||
# unnoticed. Bumping is a reviewed commit.
|
||||
- name: Install WiX toolset
|
||||
run: dotnet tool install --global wix --version 5.0.2
|
||||
|
||||
# Version pinned; roll-forward because the tool targets an older runtime than the SDK.
|
||||
- name: Install AzureSignTool
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# BEFORE the build: wix embeds the file as it finds it, so a loader signed afterwards
|
||||
# would leave the copy inside the MSI unsigned. Devices under an AllSigned policy need
|
||||
# the embedded loader to carry a signature of its own.
|
||||
- name: Sign bootstrap loader (Authenticode)
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
& azuresigntool.exe sign --verbose `
|
||||
--azure-key-vault-url "$env:CODE_SIGN_KEY_VAULT_URL" `
|
||||
--azure-key-vault-accesstoken "$env:CODE_SIGN_AKV_ACCESS_TOKEN" `
|
||||
--azure-key-vault-certificate "$env:CODE_SIGN_CERTIFICATE_NAME" `
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor bootstrap runner' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
$env:RUNNER_SCRIPT
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Build MSI
|
||||
shell: pwsh
|
||||
run: |
|
||||
# wix resolves File/@Source relative to the working directory, not the wxs —
|
||||
# build from inside the project dir (same as the local build documented in the wxs).
|
||||
$out = Join-Path (Get-Location) $env:STABLE_MSI
|
||||
Push-Location $env:MSI_PROJECT_DIR
|
||||
wix build Package.wxs -arch x64 -o $out
|
||||
Pop-Location
|
||||
if (-not (Test-Path $env:STABLE_MSI)) { Write-Error "wix build produced no MSI"; exit 1 }
|
||||
Write-Host "Built $env:STABLE_MSI ($([math]::Round((Get-Item $env:STABLE_MSI).Length / 1KB)) KB)"
|
||||
|
||||
# Signing rewrites the MSI, so it runs before the hash and the upload. A missing
|
||||
# timestamp fails the run: without it the signature dies with the certificate.
|
||||
- name: Sign MSI (Authenticode)
|
||||
@@ -172,30 +369,30 @@ jobs:
|
||||
--timestamp-rfc3161 "$env:CODE_SIGN_TIMESTAMP_URL" `
|
||||
--file-digest sha256 `
|
||||
--description 'Autopilot Monitor Bootstrap' `
|
||||
--description-url 'https://www.autopilotmonitor.com' `
|
||||
--description-url $env:WWW_BASE_URL `
|
||||
$env:STABLE_MSI
|
||||
if ($LASTEXITCODE -ne 0) { throw "azuresigntool failed with exit code $LASTEXITCODE" }
|
||||
|
||||
# The MSI and the loader it carries -- verified here, before anything is published.
|
||||
foreach ($file in @($env:STABLE_MSI, $env:RUNNER_SCRIPT)) {
|
||||
$sig = Get-AuthenticodeSignature -FilePath $file
|
||||
if ($sig.Status -ne 'Valid') { throw "Signature on $file is '$($sig.Status)', expected 'Valid'." }
|
||||
if (-not $sig.TimeStamperCertificate) { throw "Signature on $file carries no timestamp." }
|
||||
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "$file was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
Write-Host "$file : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
# The loader was verified in sign-loader; here the MSI, before anything is published.
|
||||
$sig = Get-AuthenticodeSignature -FilePath $env:STABLE_MSI
|
||||
if ($sig.Status -ne 'Valid') { throw "Signature on $env:STABLE_MSI is '$($sig.Status)', expected 'Valid'." }
|
||||
if (-not $sig.TimeStamperCertificate) { throw "Signature on $env:STABLE_MSI carries no timestamp." }
|
||||
if ($env:CODE_SIGN_EXPECTED_SUBJECT -and $sig.SignerCertificate.Subject -notlike $env:CODE_SIGN_EXPECTED_SUBJECT) {
|
||||
throw "$env:STABLE_MSI was signed by '$($sig.SignerCertificate.Subject)', expected '$env:CODE_SIGN_EXPECTED_SUBJECT'."
|
||||
}
|
||||
Write-Host "$env:STABLE_MSI : Valid, timestamped, signer $($sig.SignerCertificate.Subject.Split(',')[0])"
|
||||
|
||||
Write-Host "SHA-256: $((Get-FileHash $env:STABLE_MSI -Algorithm SHA256).Hash.ToLowerInvariant())"
|
||||
|
||||
- name: Upload to Azure Blob Storage
|
||||
if: inputs.upload_blob
|
||||
shell: pwsh
|
||||
env:
|
||||
MSI_VERSION: ${{ steps.version.outputs.version }}
|
||||
run: |
|
||||
$containerUrl = "https://autopilotmonitoreu.blob.core.windows.net/agent"
|
||||
$containerUrl = $env:AGENT_CONTAINER_URL
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$version = "${{ steps.version.outputs.version }}"
|
||||
$version = $env:MSI_VERSION
|
||||
|
||||
# Same Send-Blob contract as build-agent.yml: no-cache on every blob because the
|
||||
# stable name rotates in place behind the download alias. No legacy mirror — the
|
||||
@@ -226,26 +423,26 @@ jobs:
|
||||
shell: pwsh
|
||||
run: |
|
||||
$localSize = (Get-Item $env:STABLE_MSI).Length
|
||||
$resp = Invoke-WebRequest -Uri "https://download.autopilotmonitor.com/agent/$env:STABLE_MSI" -Method Head -UseBasicParsing
|
||||
$resp = Invoke-WebRequest -Uri "$env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI" -Method Head -UseBasicParsing
|
||||
if ($resp.StatusCode -ne 200) { Write-Error "Alias HEAD returned $($resp.StatusCode)"; exit 1 }
|
||||
$remoteSize = [long]$resp.Headers['Content-Length'][0]
|
||||
if ($remoteSize -ne $localSize) {
|
||||
Write-Error "Size mismatch: alias serves $remoteSize bytes, local MSI is $localSize bytes"
|
||||
exit 1
|
||||
}
|
||||
Write-Host "Alias verified: https://download.autopilotmonitor.com/agent/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
Write-Host "Alias verified: $env:DOWNLOAD_ALIAS_URL/$env:STABLE_MSI ($remoteSize bytes)"
|
||||
|
||||
# Sigstore keyless provenance, same as the agent ZIP; covers the signed bytes. Skipped
|
||||
# on runs that publish nothing -- no log entry for bytes nobody receives. Verify:
|
||||
# gh attestation verify AutopilotMonitor-Bootstrap.msi --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (MSI)
|
||||
if: inputs.upload_blob
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: ${{ env.STABLE_MSI }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: AutopilotMonitor-Bootstrap-v${{ steps.version.outputs.version }}
|
||||
path: ${{ env.STABLE_MSI }}
|
||||
|
||||
+58
-18
@@ -1,8 +1,8 @@
|
||||
name: CI
|
||||
|
||||
# Tests, typechecks and drift-checks every push to main and every pull request:
|
||||
# the .NET suites, the web and MCP suites, and a guard that the generated rule
|
||||
# files still match rules/guardrails.json.
|
||||
# the .NET suites, the web and MCP suites, a guard that the generated rule
|
||||
# files still match rules/guardrails.json, and the duplicate-code check.
|
||||
#
|
||||
# The deploy workflows build but never test, so this is the only place the suites
|
||||
# actually run. It does not gate anything — main is deliberately unprotected, so a
|
||||
@@ -46,8 +46,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -80,8 +78,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -98,11 +94,11 @@ jobs:
|
||||
# windows-latest is required: the agent test projects target net48.
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -124,6 +120,9 @@ jobs:
|
||||
- name: Summary Dialog tests
|
||||
run: dotnet test src/Agent/AutopilotMonitor.SummaryDialog.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
|
||||
|
||||
- name: Push tests
|
||||
run: dotnet test src/Push/AutopilotMonitor.Push.Tests --configuration Release --no-build --verbosity quiet --logger "console;verbosity=minimal"
|
||||
|
||||
web:
|
||||
name: Web (typecheck + tests)
|
||||
runs-on: ubuntu-latest
|
||||
@@ -131,11 +130,11 @@ jobs:
|
||||
run:
|
||||
working-directory: src/Web/autopilot-monitor-web
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -157,6 +156,12 @@ jobs:
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
# Unused files, exports and dependencies (knip.json). Its own script on
|
||||
# purpose: chained into `lint`, the `-- --max-warnings 0` above would be
|
||||
# handed to knip and ESLint would lose its cap.
|
||||
- name: Unused code
|
||||
run: npm run knip
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -167,11 +172,11 @@ jobs:
|
||||
run:
|
||||
working-directory: src/McpServer/autopilot-monitor-mcp
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -179,9 +184,19 @@ jobs:
|
||||
|
||||
- run: npm ci
|
||||
|
||||
# tsconfig.json covers sources, tests and scripts; the build emits via
|
||||
# tsconfig.build.json.
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
# Same rule as the web job: any warning fails — fix new findings, never
|
||||
# raise the cap.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 0
|
||||
|
||||
- name: Unused code
|
||||
run: npm run knip
|
||||
|
||||
# The vector-search tests tokenize with the real all-MiniLM-L6-v2 model
|
||||
# (~23 MB tokenizer + ONNX weights). transformers.js caches under
|
||||
# node_modules by default, which npm ci recreates — so every CI run
|
||||
@@ -189,7 +204,7 @@ jobs:
|
||||
# (run 31252869254). Persist the model across runs instead; HF_CACHE_DIR
|
||||
# is honored by vector-search-provider.ts.
|
||||
- name: Cache embedding model
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: ${{ github.workspace }}/.hf-cache
|
||||
key: hf-model-Xenova-all-MiniLM-L6-v2
|
||||
@@ -203,11 +218,11 @@ jobs:
|
||||
name: Generated guardrails match rules/guardrails.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -226,11 +241,11 @@ jobs:
|
||||
name: Generated wire types match shared-manifests.json
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
@@ -244,8 +259,33 @@ jobs:
|
||||
|
||||
- name: Fail if the generated files drifted
|
||||
run: |
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/utils/shared-manifests.generated.ts src/Web/autopilot-monitor-web/utils/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
|
||||
if ! git diff --quiet -- src/Web/autopilot-monitor-web/lib/generated/shared-manifests.generated.ts src/Web/autopilot-monitor-web/lib/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-types.generated.ts src/McpServer/autopilot-monitor-mcp/src/generated/wire-vocabularies.generated.ts; then
|
||||
echo "::error::Generated wire types are out of date. Run 'npm run generate:manifests' in src/Web/autopilot-monitor-web and commit the result."
|
||||
git diff --stat
|
||||
exit 1
|
||||
fi
|
||||
|
||||
duplicates:
|
||||
name: Duplicate code
|
||||
runs-on: ubuntu-latest
|
||||
defaults:
|
||||
run:
|
||||
working-directory: tools/quality
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
cache-dependency-path: tools/quality/package-lock.json
|
||||
|
||||
- run: npm ci --ignore-scripts
|
||||
|
||||
# jscpd over the C# and TypeScript sources (.jscpd.json). A clone missing from
|
||||
# jscpd-baseline.json fails, and so does a baseline entry whose clone is gone,
|
||||
# so the baseline only shrinks. Locally: npm run dup in tools/quality.
|
||||
- name: Duplicate check
|
||||
run: npm run dup
|
||||
|
||||
@@ -28,6 +28,7 @@ on:
|
||||
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
|
||||
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
|
||||
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/action-pins.yml'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
@@ -37,8 +38,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
@@ -60,6 +59,7 @@ on:
|
||||
# Every other workflow: none of them changes the C# or TypeScript CodeQL analyzes.
|
||||
# paths-ignore cannot negate, so they are listed one by one; codeql.yml itself and
|
||||
# .github/codeql/ stay out of the list. A new workflow file is opt-in — add it here too.
|
||||
- '.github/workflows/action-pins.yml'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
- '.github/workflows/build-agent.yml'
|
||||
- '.github/workflows/build-msi.yml'
|
||||
@@ -69,8 +69,6 @@ on:
|
||||
- '.github/workflows/deploy-mcp.yml'
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
- '.github/workflows/indexnow-notify.yml'
|
||||
- '.github/workflows/oidc-login-test.yml'
|
||||
- '.github/workflows/pester.yml'
|
||||
- '.github/workflows/publish-scripts.yml'
|
||||
schedule:
|
||||
# Weekly, so advisories published after a merge still surface. Deliberately
|
||||
@@ -104,11 +102,11 @@ jobs:
|
||||
build-mode: none
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
if: matrix.language == 'csharp'
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: Validate & Combine Rules
|
||||
on:
|
||||
push:
|
||||
branches: [main, dev]
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'rules/gather/**'
|
||||
- 'rules/analyze/**'
|
||||
@@ -9,6 +9,7 @@ on:
|
||||
- 'rules/schema/**'
|
||||
- 'rules/scripts/**'
|
||||
- 'rules/guardrails.json'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- 'rules/gather/**'
|
||||
@@ -17,73 +18,71 @@ on:
|
||||
- 'rules/schema/**'
|
||||
- 'rules/scripts/**'
|
||||
- 'rules/guardrails.json'
|
||||
- '.github/workflows/combine-rules.yml'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
validate-and-combine:
|
||||
# Pull requests and pushes alike; read-only, because the validator is third-party code.
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
# Lockfile and no lifecycle scripts: this job holds contents:write and pushes to
|
||||
# main, so an install-time script from a hijacked package would run with a token that
|
||||
# can rewrite the repository. rules/scripts/package-lock.json pins ajv-cli, ajv-formats
|
||||
# and every transitive package (Dependabot keeps it current); ajv is pure JS and
|
||||
# needs no install hooks.
|
||||
- name: Install ajv-cli from the lockfile
|
||||
# Lockfile and no lifecycle scripts: rules/scripts/package-lock.json pins ajv,
|
||||
# ajv-formats and every transitive package (Dependabot keeps it current); ajv is pure
|
||||
# JS and needs no install hooks.
|
||||
- name: Install the rules validator from the lockfile
|
||||
working-directory: rules/scripts
|
||||
run: |
|
||||
npm ci --ignore-scripts
|
||||
echo "$PWD/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Validate Gather Rules
|
||||
run: |
|
||||
for f in rules/gather/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/gather-rule.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
- name: Validate Analyze Rules
|
||||
run: |
|
||||
for f in rules/analyze/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/analyze-rule.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
- name: Validate IME Log Patterns
|
||||
run: |
|
||||
for f in rules/ime-log-patterns/*.json; do
|
||||
echo "Validating $f..."
|
||||
ajv validate -s rules/schema/ime-log-pattern.schema.json -d "$f" --spec=draft2020 -c ajv-formats
|
||||
done
|
||||
|
||||
# guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
# auto-committed to main and shipped to the portal - the schema forbids control
|
||||
# and line-terminator characters so a "data" PR can never become code.
|
||||
- name: Validate Guardrails
|
||||
run: ajv validate -s rules/schema/guardrails.schema.json -d rules/guardrails.json --spec=draft2020 -c ajv-formats
|
||||
# Gather, analyze and IME log pattern files plus guardrails.json, each against its
|
||||
# schema; the guardrails schema is what keeps a "data" PR from becoming code in the
|
||||
# generated TypeScript (see validate.js).
|
||||
- name: Validate rules and guardrails against their schemas
|
||||
run: node rules/scripts/validate.js
|
||||
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
# Plain git instead of a third-party action: this step runs with contents:write on
|
||||
# main, so no code we do not control gets to push. Checkout leaves the job's
|
||||
# GITHUB_TOKEN in the remote credential helper, which is all `git push` needs.
|
||||
# The one job that can push to main, and it installs nothing: combine.js needs only node's
|
||||
# built-ins, so no package code ever runs next to the write token. Keep it that way -- a
|
||||
# dependency in combine.js would need its own job.
|
||||
publish:
|
||||
needs: validate
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
# Checkout leaves the job's GITHUB_TOKEN in the remote credential helper, which is all
|
||||
# `git push` needs.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Combine rules into dist/
|
||||
run: node rules/scripts/combine.js
|
||||
|
||||
# Plain git instead of a third-party action, so no code we do not control gets to push.
|
||||
# NOTE: combine.js writes THREE outputs; committing only dist/ would silently discard
|
||||
# the two generated TS modules and leave the guardrails-in-sync CI check red.
|
||||
- name: Commit generated outputs
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git add -- rules/dist/ \
|
||||
src/Web/autopilot-monitor-web/utils/guardrails.generated.ts \
|
||||
src/Web/autopilot-monitor-web/lib/generated/guardrails.generated.ts \
|
||||
src/McpServer/autopilot-monitor-mcp/src/rule-authoring.generated.ts
|
||||
if git diff --cached --quiet; then
|
||||
echo "Generated outputs unchanged - nothing to commit"
|
||||
|
||||
@@ -3,26 +3,41 @@ name: Deploy Backend (Azure Functions)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# Every job states its own permissions. This default is what the build job runs with: restore
|
||||
# and build execute third-party code (NuGet MSBuild targets, the Functions worker SDK) and must
|
||||
# never hold more than a read-only token.
|
||||
permissions:
|
||||
id-token: write # OIDC federated login (secret-less)
|
||||
contents: read
|
||||
|
||||
# Hosts (API, versions container) come from .github/scripts/Get-Endpoints.ps1.
|
||||
env:
|
||||
FUNCTION_APP: autopilotmonitor-api-eu
|
||||
HEALTH_URL: https://autopilotmonitor-api-eu.azurewebsites.net/api/health
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
CSPROJ_PATH: src/Backend/AutopilotMonitor.Functions/AutopilotMonitor.Functions.csproj
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
# Runs only this repository's scripts. The deploy identity trusts the `production`
|
||||
# environment, which admits main only.
|
||||
reserve:
|
||||
name: Reserve build number
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Backend
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login (secret-less)
|
||||
outputs:
|
||||
build_number: ${{ steps.version.outputs.build_number }}
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
build_utc: ${{ steps.version.outputs.build_utc }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -30,11 +45,6 @@ jobs:
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
# Same counter mechanism as the agent line: reserve-before-build with ETag-CAS
|
||||
# against a per-component counter blob. BuildTimestampUtc is pinned here rather
|
||||
# than left to MSBuild's default so the published manifest and the value baked
|
||||
@@ -43,7 +53,7 @@ jobs:
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/backend.txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/backend.txt"
|
||||
|
||||
$csproj = Get-Content ${{ env.CSPROJ_PATH }} -Raw
|
||||
if ($csproj -notmatch '<VersionPrefix>(\d+\.\d+)</VersionPrefix>') {
|
||||
@@ -57,20 +67,90 @@ jobs:
|
||||
"version=$version" >> $env:GITHUB_OUTPUT
|
||||
"build_utc=$buildUtc" >> $env:GITHUB_OUTPUT
|
||||
|
||||
# Build zone: no login, no secret, no id-token. Its only output is the artifact; the
|
||||
# deploy job treats that as data and never builds.
|
||||
build:
|
||||
name: Build
|
||||
needs: reserve
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup .NET 10
|
||||
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
|
||||
with:
|
||||
dotnet-version: '10.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore ${{ env.CSPROJ_PATH }}
|
||||
|
||||
- name: Build
|
||||
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
|
||||
run: dotnet build ${{ env.CSPROJ_PATH }} --configuration Release --no-restore -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ needs.reserve.outputs.build_number }} -p:BuildTimestampUtc=${{ needs.reserve.outputs.build_utc }}
|
||||
|
||||
- name: Publish
|
||||
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ steps.version.outputs.build_number }} -p:BuildTimestampUtc=${{ steps.version.outputs.build_utc }}
|
||||
run: dotnet publish ${{ env.CSPROJ_PATH }} --configuration Release --output ./publish --no-build -p:SourceRevisionId=${{ github.sha }} -p:BuildNumber=${{ needs.reserve.outputs.build_number }} -p:BuildTimestampUtc=${{ needs.reserve.outputs.build_utc }}
|
||||
|
||||
# One archive, not a folder upload: upload-artifact skips hidden files, and the
|
||||
# isolated worker cannot start without the publish output's .azurefunctions/ folder.
|
||||
- name: Package publish output
|
||||
run: tar -czf "$RUNNER_TEMP/backend-publish.tgz" -C publish .
|
||||
|
||||
- name: Upload publish output
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-publish.tgz
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# Trust zone: only this repository's scripts and pinned actions run here, never a restore
|
||||
# or build. The package is unpacked outside the checkout, so nothing in it can replace the
|
||||
# scripts this job runs.
|
||||
deploy:
|
||||
name: Deploy and verify
|
||||
needs: [reserve, build]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login (secret-less)
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download publish output
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: backend-publish
|
||||
path: ${{ runner.temp }}/backend-artifact
|
||||
|
||||
# GNU tar refuses members with '..' and strips leading '/'.
|
||||
- name: Unpack publish output
|
||||
run: |
|
||||
mkdir "$RUNNER_TEMP/publish"
|
||||
tar -xzf "$RUNNER_TEMP/backend-artifact/backend-publish.tgz" -C "$RUNNER_TEMP/publish"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Deploy to Azure Functions
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1
|
||||
uses: Azure/functions-action@c5060b3b8bb1ebbcb531abd00c822ecbaa8ea656 # v1.5.7
|
||||
with:
|
||||
app-name: ${{ env.FUNCTION_APP }}
|
||||
package: ./publish
|
||||
package: ${{ runner.temp }}/publish
|
||||
|
||||
# A green deploy step only means the package was accepted. This gate makes the
|
||||
# workflow assert what is actually being served, so the manifest published below
|
||||
@@ -78,16 +158,18 @@ jobs:
|
||||
# /api/health is IP rate-limited at 30/min; one poll per 10s stays well under.
|
||||
- name: Verify deployed build is live
|
||||
shell: pwsh
|
||||
env:
|
||||
EXPECTED_VERSION: ${{ needs.reserve.outputs.version }}
|
||||
run: |
|
||||
$expectedCommit = '${{ github.sha }}'.Substring(0, 7)
|
||||
$expectedVersion = '${{ steps.version.outputs.version }}'
|
||||
$expectedVersion = $env:EXPECTED_VERSION
|
||||
$deadline = [DateTime]::UtcNow.AddMinutes(3)
|
||||
$attempt = 0
|
||||
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 30
|
||||
$health = Invoke-RestMethod -Uri "$env:API_BASE_URL/api/health" -Method Get -TimeoutSec 30
|
||||
if ($health.commitHash -eq $expectedCommit -and $health.version -eq $expectedVersion) {
|
||||
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commitHash), built $($health.buildUtc)"
|
||||
exit 0
|
||||
@@ -107,18 +189,21 @@ jobs:
|
||||
# (rollback, stale instance) rather than a half-finished pipeline.
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
env:
|
||||
BACKEND_VERSION: ${{ needs.reserve.outputs.version }}
|
||||
BACKEND_BUILD_UTC: ${{ needs.reserve.outputs.build_utc }}
|
||||
run: |
|
||||
$manifest = [ordered]@{
|
||||
component = 'backend'
|
||||
version = '${{ steps.version.outputs.version }}'
|
||||
version = $env:BACKEND_VERSION
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
buildUtc = '${{ steps.version.outputs.build_utc }}'
|
||||
buildUtc = $env:BACKEND_BUILD_UTC
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
} | ConvertTo-Json
|
||||
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/backend.json"
|
||||
$url = "$env:VERSIONS_CONTAINER_URL/backend.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/backend.json"
|
||||
|
||||
@@ -16,20 +16,29 @@ env:
|
||||
# reference — with a sha-only tag `az containerapp update` saw the same image
|
||||
# and silently created no revision (2026-08-28).
|
||||
IMAGE_TAG: ${{ github.sha }}-${{ github.run_number }}
|
||||
HEALTH_URL: https://mcp.autopilotmonitor.com/health
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
PACKAGE_JSON: src/McpServer/autopilot-monitor-mcp/package.json
|
||||
# Hosts (MCP, versions container) come from .github/scripts/Get-Endpoints.ps1.
|
||||
|
||||
jobs:
|
||||
# One job on purpose, unlike the other deploys: all third-party code (npm ci, the build, the
|
||||
# embedding precompute) runs inside `docker build`, whose containers see neither the az
|
||||
# login nor the registry credentials nor this job's OIDC request token. That container is
|
||||
# the boundary -- never move npm onto the runner here, or the build gets the job's
|
||||
# credentials. The deploy identity trusts the `production` environment, which admits main only.
|
||||
build-and-deploy:
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy MCP Server
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# The published customer documentation lives in its own PUBLIC repo and is
|
||||
# baked into the image as the search_docs corpus (see the Dockerfile). No
|
||||
# token is needed. Checking it out here rather than vendoring it keeps the
|
||||
@@ -38,7 +47,7 @@ jobs:
|
||||
# re-run actually rolls a new revision). /health reports the SHA captured
|
||||
# below so that staleness is checkable.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
@@ -62,7 +71,7 @@ jobs:
|
||||
id: version
|
||||
shell: pwsh
|
||||
run: |
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "${{ env.VERSIONS_CONTAINER }}/mcp.txt"
|
||||
$buildNumber = & ./.github/scripts/Request-BuildNumber.ps1 -CounterUrl "$env:VERSIONS_CONTAINER_URL/mcp.txt"
|
||||
|
||||
$pkgVersion = (Get-Content ${{ env.PACKAGE_JSON }} -Raw | ConvertFrom-Json).version
|
||||
$prefix = ($pkgVersion -split '\.')[0..1] -join '.'
|
||||
@@ -117,7 +126,7 @@ jobs:
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$health = Invoke-RestMethod -Uri '${{ env.HEALTH_URL }}' -Method Get -TimeoutSec 60
|
||||
$health = Invoke-RestMethod -Uri "$env:MCP_BASE_URL/health" -Method Get -TimeoutSec 60
|
||||
if ($health.commit -eq $expectedCommit -and $health.version -eq $expectedVersion) {
|
||||
Write-Host "Live after $attempt attempt(s): $($health.version) @ $($health.commit), docs $($health.docs.commit) ($($health.docs.chunks) chunks)"
|
||||
exit 0
|
||||
@@ -148,7 +157,7 @@ jobs:
|
||||
} | ConvertTo-Json
|
||||
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/mcp.json"
|
||||
$url = "$env:VERSIONS_CONTAINER_URL/mcp.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/mcp.json"
|
||||
|
||||
@@ -5,6 +5,11 @@ name: Deploy Web (Static Web App)
|
||||
# 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.
|
||||
#
|
||||
# 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.
|
||||
|
||||
@@ -17,38 +22,39 @@ on:
|
||||
- '.github/workflows/deploy-web.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
# Every job states its own permissions. This default is what the build job runs with: npm ci
|
||||
# (install scripts included) and next build execute third-party code and must never hold more
|
||||
# than a read-only token.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing (artifact attestation)
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
# Hosts (API, portal, storage) come from .github/scripts/Get-Endpoints.ps1, never from a
|
||||
# repository variable.
|
||||
env:
|
||||
PORTAL_VERSION_URL: https://portal.autopilotmonitor.com/version.json
|
||||
VERSIONS_CONTAINER: https://autopilotmonitoreu.blob.core.windows.net/versions
|
||||
WEB_DIR: src/Web/autopilot-monitor-web
|
||||
# Exact version: `npx --package` resolves from the registry at run time, so the pin is the
|
||||
# only thing that keeps the deploy step's code fixed. Bump deliberately.
|
||||
SWA_CLI_VERSION: 2.0.10
|
||||
|
||||
jobs:
|
||||
build_and_deploy_job:
|
||||
# Build zone: no login, no id-token. The one secret here is the Application Insights
|
||||
# connection string, which ships inside the public bundle anyway.
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
name: Build and Deploy Job
|
||||
outputs:
|
||||
docs_sha: ${{ steps.docs.outputs.sha }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
lfs: false
|
||||
fetch-depth: 0 # Full history needed for git-based sitemap lastmod dates
|
||||
|
||||
# Only the version manifest at the very end needs Azure; logging in here rather than
|
||||
# next to that step keeps a credential problem from surfacing after a live deploy.
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
# The customer docs repo is the single source of the "What's new" panel: the prebuild
|
||||
# step (scripts/whats-new/build-whats-new.js) turns changelog/*.md into
|
||||
@@ -57,7 +63,7 @@ jobs:
|
||||
# workflow runs again (push under src/Web/** or a manual dispatch); versions/web.json
|
||||
# records which docs commit is live.
|
||||
- name: Check out documentation bundle
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: autopilotmonitor/autopilotmonitor-docs
|
||||
@@ -68,7 +74,7 @@ jobs:
|
||||
id: docs
|
||||
run: echo "sha=$(git -C whats-new-docs rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: npm
|
||||
@@ -105,12 +111,6 @@ jobs:
|
||||
# and the default app for browsers without a stored selection (legacy|primary).
|
||||
NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID: ${{ vars.NEXT_PUBLIC_ENTRA_LEGACY_CLIENT_ID }}
|
||||
NEXT_PUBLIC_ENTRA_DEFAULT_APP: ${{ vars.NEXT_PUBLIC_ENTRA_DEFAULT_APP }}
|
||||
NEXT_PUBLIC_ENTRA_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI: ${{ vars.NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI }}
|
||||
NEXT_PUBLIC_API_BASE_URL: ${{ vars.NEXT_PUBLIC_API_BASE_URL }}
|
||||
# Browsers load the live stats through it, and the build reads it once to bake the
|
||||
# numbers of this moment into the landing page's static HTML (lib/platformStats.ts).
|
||||
NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL: ${{ vars.NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL }}
|
||||
NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING: ${{ secrets.NEXT_PUBLIC_APPINSIGHTS_CONNECTION_STRING }}
|
||||
# Build stamp for public/version.json (scripts/generate-version.js in prebuild);
|
||||
# the verify step below polls the deployed file for exactly this commit.
|
||||
@@ -120,6 +120,18 @@ jobs:
|
||||
# Star counter from the step above; empty when the lookup failed.
|
||||
NEXT_PUBLIC_GITHUB_STARS: ${{ steps.stars.outputs.count }}
|
||||
run: |
|
||||
# All from the endpoint registry (Load endpoints). Empty, the portal would silently call
|
||||
# localhost:7071. The stats manifest is what browsers load the live numbers from, and the
|
||||
# build reads it once to bake the numbers of this moment into the landing page's static
|
||||
# HTML (lib/platformStats.ts). The redirect URIs are only the fallback for the static
|
||||
# render; in the browser lib/msalConfig.ts uses window.location.origin.
|
||||
export NEXT_PUBLIC_API_BASE_URL="$API_BASE_URL"
|
||||
export NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL="$STATS_CONTAINER_URL"
|
||||
export NEXT_PUBLIC_ENTRA_REDIRECT_URI="$WWW_BASE_URL"
|
||||
export NEXT_PUBLIC_ENTRA_POST_LOGOUT_REDIRECT_URI="$WWW_BASE_URL"
|
||||
test -n "$NEXT_PUBLIC_API_BASE_URL" || { echo "::error::API_BASE_URL is empty - the portal would call localhost"; exit 1; }
|
||||
test -n "$NEXT_PUBLIC_PLATFORM_STATS_MANIFEST_URL" || { echo "::error::STATS_CONTAINER_URL is empty"; exit 1; }
|
||||
test -n "$NEXT_PUBLIC_ENTRA_REDIRECT_URI" || { echo "::error::WWW_BASE_URL is empty"; exit 1; }
|
||||
npm run build
|
||||
test -f out/version.json || { echo "out/version.json missing - export did not run"; exit 1; }
|
||||
test -f out/whats-new.json || { echo "out/whats-new.json missing - whats-new prebuild did not run"; exit 1; }
|
||||
@@ -136,19 +148,37 @@ jobs:
|
||||
echo "archive=$archive" >> "$GITHUB_OUTPUT"
|
||||
sha256sum "$archive"
|
||||
|
||||
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
|
||||
# commit, workflow file) in a public transparency log.
|
||||
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (portal export)
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4
|
||||
with:
|
||||
subject-path: ${{ steps.package.outputs.archive }}
|
||||
|
||||
- name: Upload build artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
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.
|
||||
deploy:
|
||||
name: Deploy to Static Web App
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
|
||||
steps:
|
||||
# For staticwebapp.config.json only.
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
|
||||
- name: Unpack export
|
||||
run: |
|
||||
mkdir "$WEB_DIR/out"
|
||||
tar -xzf "$RUNNER_TEMP/portal/portal-${GITHUB_SHA::7}.tar.gz" -C "$WEB_DIR/out"
|
||||
|
||||
# Upload only -- out/ is already built. --swa-config-location points at the app root,
|
||||
# where staticwebapp.config.json lives (next export does not copy it into out/).
|
||||
@@ -163,6 +193,48 @@ jobs:
|
||||
--env production \
|
||||
--no-use-keychain
|
||||
|
||||
# Trust zone: only this repository's scripts and pinned actions run here. The deploy
|
||||
# identity trusts the `production` environment, which admits main only.
|
||||
record:
|
||||
name: Attest, verify and record
|
||||
needs: [build, deploy]
|
||||
runs-on: ubuntu-latest
|
||||
environment: production
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # keyless Sigstore signing, OIDC login for the manifest
|
||||
attestations: write # store the provenance attestation
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Download build artifact
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: portal-${{ github.sha }}
|
||||
path: ${{ runner.temp }}/portal
|
||||
|
||||
- name: Locate the export archive
|
||||
id: archive
|
||||
run: |
|
||||
path="$RUNNER_TEMP/portal/portal-${GITHUB_SHA::7}.tar.gz"
|
||||
test -f "$path" || { echo "::error::Export archive for this commit is missing"; exit 1; }
|
||||
echo "path=$path" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Sigstore keyless provenance: binds the export digest to this workflow run (repo, ref,
|
||||
# commit, workflow file) in a public transparency log.
|
||||
# Verify: gh attestation verify portal-<sha>.tar.gz --repo okieselbach/AutopilotMonitor
|
||||
- name: Attest build provenance (portal export)
|
||||
uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2
|
||||
with:
|
||||
subject-path: ${{ steps.archive.outputs.path }}
|
||||
|
||||
# The CLI reporting success proves the upload, not that the production host serves the
|
||||
# new build -- verify against the live /version.json (Cache-Control: no-store via
|
||||
# staticwebapp.config.json) before publishing the version manifest.
|
||||
@@ -176,7 +248,7 @@ jobs:
|
||||
while ([DateTime]::UtcNow -lt $deadline) {
|
||||
$attempt++
|
||||
try {
|
||||
$v = Invoke-RestMethod -Uri '${{ env.PORTAL_VERSION_URL }}' -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
|
||||
$v = Invoke-RestMethod -Uri "$env:PORTAL_BASE_URL/version.json" -Method Get -TimeoutSec 30 -Headers @{ 'Cache-Control' = 'no-cache' }
|
||||
if ($v.commit -eq $expectedCommit) {
|
||||
Write-Host "Live after $attempt attempt(s): web @ $($v.commit), built $($v.buildUtc)"
|
||||
exit 0
|
||||
@@ -190,22 +262,36 @@ jobs:
|
||||
|
||||
throw "Portal did not serve commit $expectedCommit within 5 minutes -- deployment not verified, version manifest NOT published"
|
||||
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
# Cross-component deploy record, written last and only on a verified deploy --
|
||||
# same contract as versions/backend.json and versions/mcp.json. docsCommit says which
|
||||
# docs revision the live What's new panel was generated from (as for the MCP corpus).
|
||||
# The docs commit comes from the build job, so it arrives as data and is checked
|
||||
# against the shape of a commit id before it is used.
|
||||
- name: Publish version manifest
|
||||
shell: pwsh
|
||||
env:
|
||||
DOCS_SHA: ${{ needs.build.outputs.docs_sha }}
|
||||
run: |
|
||||
if ($env:DOCS_SHA -notmatch '^[0-9a-f]{40}$') {
|
||||
throw "The build job handed over no valid docs commit -- version manifest NOT published."
|
||||
}
|
||||
$manifest = [ordered]@{
|
||||
component = 'web'
|
||||
commit = '${{ github.sha }}'.Substring(0, 7)
|
||||
deployedUtc = [DateTime]::UtcNow.ToString('o')
|
||||
runId = '${{ github.run_id }}'
|
||||
docsCommit = '${{ steps.docs.outputs.sha }}'.Substring(0, 7)
|
||||
docsCommit = $env:DOCS_SHA.Substring(0, 7)
|
||||
} | ConvertTo-Json
|
||||
|
||||
$authHeaders = & ./.github/scripts/Get-StorageAuthHeaders.ps1
|
||||
$url = "${{ env.VERSIONS_CONTAINER }}/web.json"
|
||||
$url = "$env:VERSIONS_CONTAINER_URL/web.json"
|
||||
# no-cache: rotating pointer, must never be served stale by any fronting cache.
|
||||
Invoke-RestMethod -Uri $url -Method Put -Headers ($authHeaders + @{ 'x-ms-blob-type' = 'BlockBlob'; 'x-ms-blob-cache-control' = 'no-cache' }) -Body $manifest -ContentType 'application/json' | Out-Null
|
||||
Write-Host "Published versions/web.json"
|
||||
|
||||
@@ -3,24 +3,34 @@ name: IndexNow Notify (Bing/Yandex)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
# The job only talks to public endpoints; it never needs a repository token.
|
||||
permissions: {}
|
||||
# Read-only, for the checkout of the endpoint registry; beyond that the job only talks to
|
||||
# public endpoints.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
runs-on: ubuntu-latest
|
||||
name: Submit URLs to IndexNow
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Load endpoints
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/Get-Endpoints.ps1 -ExportToGitHubEnv
|
||||
|
||||
- name: Notify IndexNow
|
||||
run: |
|
||||
URLS=$(curl -s https://www.autopilotmonitor.com/sitemap.xml | grep -oP '<loc>\K[^<]+')
|
||||
URLS=$(curl -s "$WWW_BASE_URL/sitemap.xml" | grep -oP '<loc>\K[^<]+')
|
||||
URL_JSON=$(echo "$URLS" | jq -R -s 'split("\n") | map(select(length > 0))')
|
||||
RESPONSE=$(curl -s -o /dev/null -w "%{http_code}" -X POST "https://api.indexnow.org/indexnow" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"host\": \"www.autopilotmonitor.com\",
|
||||
\"host\": \"${WWW_BASE_URL#https://}\",
|
||||
\"key\": \"8f1710b5-f14b-477e-938b-6c01ef851661\",
|
||||
\"keyLocation\": \"https://www.autopilotmonitor.com/IndexNow.txt\",
|
||||
\"keyLocation\": \"$WWW_BASE_URL/IndexNow.txt\",
|
||||
\"urlList\": $URL_JSON
|
||||
}")
|
||||
echo "IndexNow response: HTTP $RESPONSE"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# TEMPORARY: proves the secret-less OIDC federated login works end-to-end.
|
||||
# Delete after the cross-tenant migration is verified (or keep as a cheap smoke test).
|
||||
name: OIDC Login Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
login-test:
|
||||
runs-on: ubuntu-latest
|
||||
name: Federated login + read check
|
||||
steps:
|
||||
- name: Login to Azure (OIDC)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
client-id: ${{ secrets.AZURE_CLIENT_ID }}
|
||||
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
|
||||
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
|
||||
|
||||
- name: Prove identity and RBAC scope
|
||||
run: |
|
||||
az account show --query "{subscription:name, subscriptionId:id, tenantId:tenantId, user:user.name}" -o json
|
||||
echo "--- function app visible with Contributor scope? ---"
|
||||
az functionapp list --query "[].name" -o json
|
||||
@@ -1,30 +0,0 @@
|
||||
name: Pester (Bootstrap scripts)
|
||||
|
||||
# Pull-request view of the bootstrap script gates. Like ci.yml it reports rather than
|
||||
# blocks the merge button.
|
||||
#
|
||||
# There is deliberately no push trigger: publish-scripts.yml runs the very same
|
||||
# reusable gate workflow on every push to main and blocks the upload to the download
|
||||
# alias on it. A push trigger here would only duplicate that run, with no consequence.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'scripts/Bootstrap/**'
|
||||
- 'scripts/CustomerSetup/**'
|
||||
- '.github/scripts/Publish-BootstrapScripts.ps1'
|
||||
- '.github/workflows/bootstrap-script-gates.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: pester-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit.
|
||||
gates:
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
@@ -23,6 +23,14 @@ on:
|
||||
required: true
|
||||
type: boolean
|
||||
default: false
|
||||
channel:
|
||||
description: 'release: every script. dev: only the two -Dev scripts, to try a change on dev devices first'
|
||||
required: true
|
||||
type: choice
|
||||
options:
|
||||
- release
|
||||
- dev
|
||||
default: release
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -36,31 +44,38 @@ concurrency:
|
||||
|
||||
# Authenticode for the customer scripts. Same identity and same repository variables as
|
||||
# build-agent.yml -- the loader on every device pins this publisher, so the two must not
|
||||
# drift apart.
|
||||
# drift apart. The publisher takes every host, the timestamp server included, from
|
||||
# .github/scripts/Get-Endpoints.ps1.
|
||||
env:
|
||||
CODE_SIGN_CLIENT_ID: ${{ vars.CODE_SIGN_CLIENT_ID }}
|
||||
CODE_SIGN_CERTIFICATE_NAME: ${{ vars.CODE_SIGN_CERTIFICATE_NAME }}
|
||||
CODE_SIGN_KEY_VAULT_URL: ${{ vars.CODE_SIGN_KEY_VAULT_URL }}
|
||||
CODE_SIGN_EXPECTED_SUBJECT: ${{ vars.CODE_SIGN_EXPECTED_SUBJECT }}
|
||||
CODE_SIGN_TIMESTAMP_URL: http://timestamp.acs.microsoft.com
|
||||
|
||||
jobs:
|
||||
# $/ is the self-repository form: this repository at the running commit, never a
|
||||
# path some earlier step could have written to the workspace.
|
||||
#
|
||||
# A push with [skip publish] in one of its commit messages runs neither job and uploads
|
||||
# nothing; the gates' own push trigger, whose paths cover these, still checks it. Never
|
||||
# [skip actions] for that: it drops every check of the push, CI and these gates included.
|
||||
gates:
|
||||
if: github.event_name != 'push' || !contains(join(github.event.commits.*.message, ' '), '[skip publish]')
|
||||
uses: $/.github/workflows/bootstrap-script-gates.yml
|
||||
|
||||
publish:
|
||||
name: Publish to the download alias
|
||||
needs: gates
|
||||
runs-on: windows-latest
|
||||
# Both identities trust only the `release` environment, which admits main only.
|
||||
environment: release
|
||||
# Job-scoped: only the publish job federates to Azure, the gates job never holds an
|
||||
# OIDC token it does not need.
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write # OIDC federated login for the storage writes (secret-less)
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@@ -74,9 +89,7 @@ jobs:
|
||||
}
|
||||
|
||||
# First, and only to fetch the Key Vault token: azure/login sets the default az
|
||||
# account and the storage login below has to be the one that stays. See D-223 --
|
||||
# both federated credentials are bound to the ref, so this job must never carry a
|
||||
# GitHub `environment:`.
|
||||
# account and the storage login below has to be the one that stays.
|
||||
- name: Login to Azure (code-signing identity)
|
||||
uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3.1.0
|
||||
with:
|
||||
@@ -113,18 +126,20 @@ jobs:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
|
||||
|
||||
# A push publishes the release channel; only a dispatch can choose dev.
|
||||
- name: Publish bootstrap + customer scripts
|
||||
shell: pwsh
|
||||
env:
|
||||
DOTNET_ROLL_FORWARD: LatestMajor
|
||||
PUBLISH_CHANNEL: ${{ inputs.channel || 'release' }}
|
||||
run: |
|
||||
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
|
||||
./.github/scripts/Publish-BootstrapScripts.ps1 `
|
||||
-Channel $env:PUBLISH_CHANNEL `
|
||||
-LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' `
|
||||
-SigningToken $env:CODE_SIGN_AKV_ACCESS_TOKEN `
|
||||
-KeyVaultUrl $env:CODE_SIGN_KEY_VAULT_URL `
|
||||
-CertificateName $env:CODE_SIGN_CERTIFICATE_NAME `
|
||||
-TimestampUrl $env:CODE_SIGN_TIMESTAMP_URL `
|
||||
-ExpectedPublisher $env:CODE_SIGN_EXPECTED_SUBJECT `
|
||||
-SignedOutputDir "$env:RUNNER_TEMP/signed-scripts" `
|
||||
-DryRun:$dryRun
|
||||
@@ -135,7 +150,7 @@ jobs:
|
||||
# when someone wants to look at the file.
|
||||
- name: Upload signed scripts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: signed-scripts
|
||||
path: ${{ runner.temp }}/signed-scripts
|
||||
|
||||
@@ -31,6 +31,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Agent.V2.C
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.SummaryDialog.Tests", "src\Agent\AutopilotMonitor.SummaryDialog.Tests\AutopilotMonitor.SummaryDialog.Tests.csproj", "{4F1234C1-9387-4D2C-93E0-713698A2DFCB}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Push", "Push", "{09266D87-DDB8-2F61-A22F-10D1DD295F4F}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Push", "src\Push\AutopilotMonitor.Push\AutopilotMonitor.Push.csproj", "{2C3E71E4-0049-45A5-839D-F4171B47D5F3}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutopilotMonitor.Push.Tests", "src\Push\AutopilotMonitor.Push.Tests\AutopilotMonitor.Push.Tests.csproj", "{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -161,6 +167,30 @@ Global
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x64.Build.0 = Release|Any CPU
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3}.Release|x86.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x64.Build.0 = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -179,5 +209,8 @@ Global
|
||||
{11B36E5C-B512-4D1E-A76B-E5AD866B033C} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{95A09BF0-4E22-41D9-9B43-8D6ECC2C8AD0} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{4F1234C1-9387-4D2C-93E0-713698A2DFCB} = {096B6098-F141-7114-B090-72D1899D8853}
|
||||
{09266D87-DDB8-2F61-A22F-10D1DD295F4F} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{2C3E71E4-0049-45A5-839D-F4171B47D5F3} = {09266D87-DDB8-2F61-A22F-10D1DD295F4F}
|
||||
{6BB95C52-F9D5-4C48-A107-B2B1FD5EE302} = {09266D87-DDB8-2F61-A22F-10D1DD295F4F}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
||||
@@ -8,5 +8,8 @@
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
<MSBuildTreatWarningsAsErrors>true</MSBuildTreatWarningsAsErrors>
|
||||
<WarningsNotAsErrors>NU1901;NU1902;NU1903;NU1904</WarningsNotAsErrors>
|
||||
<!-- Run the IDE code-style analyzers in the build too, so the rules .editorconfig raises
|
||||
to warning fail it like any compiler warning. -->
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
+1
-1
Submodule infra updated: be0165a223...7589909f54
+1
-1
Submodule internal updated: fd299a3c0d...f7d94b395a
@@ -1,46 +1,75 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-001",
|
||||
"title": "Win32 App Detection Script Failure",
|
||||
"description": "Detects when a Win32 app detection script fails or returns unexpected results.",
|
||||
"title": "App Not Detected After Install (0x87D1041C)",
|
||||
"description": "Detects when Intune's detection check right after an app's installer finished did not find the app, so Intune reports the install as failed (0x87D1041C).",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "2.0.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"baseConfidence": 60,
|
||||
"baseConfidence": 90,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"required": true,
|
||||
"suppressByEvent": {
|
||||
"eventType": "app_install_completed",
|
||||
"joinField": "appId"
|
||||
}
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "A Win32 app detection script failed or did not detect the app after installation. This causes the app to appear as 'not installed' despite successful installation. Detection failures that were later resolved by a successful install of the same app are suppressed and do not raise this finding.",
|
||||
"explanation": "The Intune Management Extension runs an app's detection twice: before the install, to decide whether the install is needed, and right after the installer finishes. Here the installer finished, but the check afterwards did not find the app. Intune therefore reports the install as failed with **0x87D1041C** (*application not detected after installation completed successfully*), even though the installer itself succeeded.\n\nFor Win32 apps the detection rule does not match what the installer left on the device. Common causes:\n\n- The rule checks a path or registry key the installer does not create (wrong path, version folder or bitness, e.g. HKLM\\SOFTWARE\\... versus HKLM\\SOFTWARE\\WOW6432Node\\...).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after the check has run.\n- A detection script exits 0 without writing to stdout, which Intune treats as not detected.\n- The installer writes to the user profile while the rule looks in system scope, or the other way round.\n\nMicrosoft Store (WinGet) apps have no detection rule to edit: Intune asks WinGet whether the package is installed in the app's install context.\n\nWhen the installer requests a restart with its return code, Intune checks again after the restart; this finding then means the app was still missing afterwards.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Fix detection rules",
|
||||
"title": "Check the detection rule against an installed device",
|
||||
"steps": [
|
||||
"Review the app's detection rules in Intune",
|
||||
"Verify file/registry paths are correct",
|
||||
"Test detection script manually on a device"
|
||||
"Open the app in Intune → Properties → Detection rules and note exactly what the rule checks",
|
||||
"Install the app on a test device outside Autopilot and confirm that the file, registry key or script result exists right after the installer exits",
|
||||
"For registry rules, check both HKLM\\SOFTWARE\\... and HKLM\\SOFTWARE\\WOW6432Node\\... depending on the installer's bitness",
|
||||
"For script rules, run the script as SYSTEM: it must write at least one line to stdout and exit 0 when the app is present",
|
||||
"If the installer completes its work later, detect something it writes synchronously, such as the MSI product code under HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Microsoft Store (WinGet) apps",
|
||||
"steps": [
|
||||
"Check the package identifier and the install behavior (System or User) of the app in Intune",
|
||||
"Install the package manually in the same context on a test device and confirm it is reported as installed"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Win32 app detection rules in Intune",
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{detection_failure.appName}} was not detected after its installer finished",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{detection_failure.appId}}",
|
||||
"name": "{{detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{detection_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
"win32"
|
||||
"win32",
|
||||
"winget",
|
||||
"0x87d1041c"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects app installation failures with specific exit codes or HRESULT error codes and suggests fixes.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -41,6 +41,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{install_error.appName}} failed to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{install_error.appId}}",
|
||||
"name": "{{install_error.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{install_error.exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{install_error.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"msi",
|
||||
|
||||
@@ -33,9 +33,25 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{dependency_failure.appName}} failed because a dependency did not install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{dependency_failure.appId}}",
|
||||
"name": "{{dependency_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{dependency_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"dependency"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when disk space is critically low during enrollment, which can cause app installation failures. Confidence increases when app installations actually failed while disk space was low.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -71,6 +71,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Free disk space dropped to {{low_disk.value}} GB",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "disk",
|
||||
"name": "Disk space"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Free space (GB)",
|
||||
"value": "{{low_disk.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Measured",
|
||||
"value": "{{low_disk.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"disk-space",
|
||||
|
||||
@@ -33,10 +33,26 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The content download of {{download_timeout.appName}} timed out or failed",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{download_timeout.appId}}",
|
||||
"name": "{{download_timeout.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{download_timeout.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"download",
|
||||
"timeout"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the same app installation is attempted 3 or more times during one enrollment — a retry loop caused by unhonored reboot requests, repeated failures, or detection mismatches.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -35,6 +35,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{install_retry_loop.groupKey}}",
|
||||
"name": "{{install_retry_loop.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Install starts",
|
||||
"value": "{{install_retry_loop.count}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "First start",
|
||||
"value": "{{install_retry_loop.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the app tracking summary reports multiple failed app installations.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -50,6 +50,17 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{summary_errors.value}} app installations failed",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed apps",
|
||||
"value": "{{summary_errors.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"summary",
|
||||
|
||||
@@ -32,11 +32,32 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{slow_app_install.appName}} took {{slow_app_install.durationFormatted}} to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{slow_app_install.appId}}",
|
||||
"name": "{{slow_app_install.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Install time",
|
||||
"value": "{{slow_app_install.durationSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Started",
|
||||
"value": "{{slow_app_install.startTimestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"performance",
|
||||
"slow-install",
|
||||
"warning"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-APP-010",
|
||||
"title": "App Reverted to Error After Successful Install",
|
||||
"description": "Detects when a specific app (matched by appId) was first reported as successfully installed but subsequently reported an IME enforcement error — while enrollment completed successfully. The app may be broken post-enrollment.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "app_regression",
|
||||
"source": "event_correlation",
|
||||
"eventType": "app_install_completed",
|
||||
"correlateEventType": "app_install_failed",
|
||||
"joinField": "appId",
|
||||
"dataField": "errorPatternId",
|
||||
"operator": "equals",
|
||||
"value": "IME-ERROR-REPORT",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "enrollment_succeeded",
|
||||
"source": "event_type",
|
||||
"eventType": "enrollment_complete",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"confidenceThreshold": 70,
|
||||
"explanation": "An app was initially reported as **successfully installed** (`app_install_completed`) but the IME (Intune Management Extension) subsequently reported an **Error** state for the same app via an `IME-ERROR-REPORT` event — while the overall enrollment completed successfully.\n\nThis pattern occurs when the IME re-evaluates enforcement state after the initial install report and detects a failure, e.g. `EnforcementState` transitions from `Success` → `Error`. The affected app and its details are captured in the matched evidence.\n\nCommon causes:\n- A post-install detection check fails (detection rule mismatch)\n- The enforcement error code `-2016345060` (0x87D13B9C) indicates the app state diverged from expected\n- The app may appear installed in the ESP summary but is in an error state on the device\n\n**The enrollment succeeded, but the affected app requires attention.**",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the app's post-install enforcement state",
|
||||
"steps": [
|
||||
"Note the appId and appName from the rule finding — these identify the exact affected app",
|
||||
"Check the Intune portal: Devices > [Device] > Apps — confirm the current enforcement state of the app",
|
||||
"Review IME logs on the device: C:\\ProgramData\\Microsoft\\IntuneManagementExtension\\Logs\\IntuneManagementExtension.log",
|
||||
"Search the log for the appId and look for 'EnforcementState' transitions (Success → Error) near the enrollment time",
|
||||
"Error -2016345060 (0x87D13B9C) typically indicates a detection failure after install — verify the app's detection rule in Intune",
|
||||
"Trigger a manual sync (Company Portal or Settings > Accounts > Access work or school > Sync) to force re-evaluation"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Fix detection or installation issues",
|
||||
"steps": [
|
||||
"If detection fails: correct the detection rule (file path, registry key, or PowerShell script) in the Intune app configuration",
|
||||
"If the app itself is broken: redeploy or reassign the app to trigger a fresh install",
|
||||
"Consider adding a post-install validation script to catch this scenario proactively"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{ "title": "Troubleshoot Win32 app installations", "url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install" },
|
||||
{ "title": "Intune Management Extension logs", "url": "https://learn.microsoft.com/en-us/mem/intune/apps/intune-management-extension" }
|
||||
],
|
||||
"tags": ["apps", "win32", "enforcement", "post-install", "correlation"]
|
||||
}
|
||||
@@ -43,6 +43,32 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-app-management#step-4-return-codes"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "{{enforcement_error_resolved.eventA_appName}} reported an install error but was detected as installed",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{enforcement_error_resolved.joinValue}}",
|
||||
"name": "{{enforcement_error_resolved.eventA_appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{enforcement_error_resolved.eventA_exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{enforcement_error_resolved.eventA_errorCode}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Detected installed after",
|
||||
"value": "{{enforcement_error_resolved.timeDeltaSeconds}}",
|
||||
"format": "duration"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"enforcement",
|
||||
@@ -50,5 +76,5 @@
|
||||
"unmapped",
|
||||
"resolved"
|
||||
],
|
||||
"version": "1.1.0"
|
||||
"version": "1.2.0"
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed because Intune could not verify an app was installed (HRESULT 0x87D1041C). The installer ran but the detection rule did not match afterwards.",
|
||||
"severity": "critical",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -27,9 +27,9 @@
|
||||
"signal": "app_with_detection_failure",
|
||||
"source": "event_type",
|
||||
"eventType": "app_install_failed",
|
||||
"dataField": "detectionResult",
|
||||
"operator": "equals",
|
||||
"value": "NotDetected",
|
||||
"dataField": "errorCode",
|
||||
"operator": "regex",
|
||||
"value": "^(-2016345060|0x87d1041c)$",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
@@ -42,7 +42,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "App **{{appName}}** failed during the Enrollment Status Page with HRESULT **{{errorCode}}** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer writes the detection artefact AFTER the ESP's detection probe runs (e.g. async post-install task, requires reboot).\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"explanation": "App **{{app_with_detection_failure.appName}}** failed during the Enrollment Status Page with HRESULT **0x87D1041C** — *Application not detected after installation completed successfully*. The installer reported success but the Intune detection rule (file/registry/script) did not find the app afterwards, so the ESP Apps subcategory failed and the device's enrollment terminated.\n\nThis is a confirmed detection-rule mismatch, NOT an install timeout — the install itself most likely completed (or the installer exited 0). Common root causes:\n\n- The detection rule looks for a registry/file path that the installer doesn't create (wrong path, wrong bitness — e.g. checking HKLM\\SOFTWARE\\... on a 32-bit installer that lands under WOW6432Node).\n- The installer finishes its work later (an async task, a service, or a restart it needs but does not request with a restart return code), after Intune's check has run.\n- The detection script returns 0 with no stdout (Intune treats empty stdout as \"not detected\").\n- The installer wrote files to a user profile but the rule looks in System scope (or vice-versa).",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Validate the detection rule against an installed device",
|
||||
@@ -66,7 +66,7 @@
|
||||
{
|
||||
"title": "Cross-check the agent's per-app event",
|
||||
"steps": [
|
||||
"Look at the app_install_failed event in this session for DataJson.detectionResult — NotDetected confirms Intune's detection probe ran and returned negative",
|
||||
"Look at the app_install_failed event of this app: errorCode 0x87D1041C (the IME log writes it as -2016345060) is Intune's own verdict that its check right after the installer did not find the app",
|
||||
"Look at app_install_failed.bytesDownloaded vs bytesTotal — if they match, the package downloaded fully (install error vs. download error)",
|
||||
"Cross-reference DataJson.exitCode and hresultFromWin32 — these come from the installer itself and may explain why the detection artefact was not written"
|
||||
]
|
||||
@@ -82,6 +82,22 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/apps-win32-add#step-4-detection-rules"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{app_with_detection_failure.appName}} installed, but its detection rule did not find it",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{app_with_detection_failure.appId}}",
|
||||
"name": "{{app_with_detection_failure.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"detection",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Enrollment Status Page failed on the Apps subcategory and the agent's AppX deployment log scan identified a suspected MSIX/Store package. Store/MSIX apps install outside the Win32 pipeline, so the regular per-app tracking sees nothing — this correlation names the likely culprit.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -123,6 +123,36 @@
|
||||
"url": "https://learn.microsoft.com/en-us/mem/intune/apps/store-apps-microsoft"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "A Store or MSIX app failed during the ESP: {{appx_candidate.value}}",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appx_candidate.value}}",
|
||||
"name": "{{appx_candidate.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{appx_esp_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "ESP phase",
|
||||
"value": "{{appx_esp_category.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{appx_top_candidate_package.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Candidates",
|
||||
"value": "{{appx_candidate_count.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"appx",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The IME reported its user-session completion line, but at least one required app stayed pending — without ever failing or even starting to install — so the agent kept deferring the enrollment completion signal. Typically an assignment/requirement problem (requirement rules never met, dependency never satisfied, or the app needs a context that is not available during ESP, e.g. a VPN client waiting for user sign-in to a portal).",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -76,5 +76,20 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Completion waited on {{apps_pending.value}} pending app(s)",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Pending apps",
|
||||
"value": "{{apps_pending.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Apps",
|
||||
"value": "{{pending_app_names.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["apps", "esp", "completion", "assignment", "pending"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "One or more required user-ESP apps never started installing while the ESP AccountSetup apps gate waited on them — the agent observed the ESP page exit with those apps still in a non-terminal, never-started state. Unlike ANALYZE-APP-015 (which reports that completion was DEFERRED because of a pending app, and stays silent once the session reaches a terminal verdict) this rule reports the delivery gap itself and fires regardless of the final outcome: an enrollment that Windows called done while required apps never ran is a successful enrollment with missing software, and the operator must see it.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -102,6 +102,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Required apps never started installing; first: {{appName.value}}",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appName.appId}}",
|
||||
"name": "{{appName.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "First app",
|
||||
"value": "{{appName.value}}"
|
||||
},
|
||||
{
|
||||
"label": "State",
|
||||
"value": "{{state.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"esp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects Win32 app installs that failed because the Windows Installer was busy with another installation (HRESULT 0x80070652 / MSI exit code 1618, ERROR_INSTALL_ALREADY_RUNNING). The IME retries on a fixed budget (~15-17 minutes across several attempts) and then reports the app as failed; when the app is ESP-blocking, the Device Setup Apps subcategory terminates with it.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 70,
|
||||
@@ -81,6 +81,27 @@
|
||||
"url": "https://learn.microsoft.com/en-us/troubleshoot/mem/intune/app-management/troubleshoot-win32-app-install"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "{{mutex_blocked_install.appName}} was blocked by another installation",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{mutex_blocked_install.appId}}",
|
||||
"name": "{{mutex_blocked_install.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Exit code",
|
||||
"value": "{{installer_exit_1618.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failed_with_mutex_code.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"msiexec",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects Win32 apps whose installer exit code maps to Soft reboot or Hard reboot in the app's Intune return-code table (the '[Win32App] lpExitCode is defined as ...' line). Hard reboot stops app processing and restarts the device in Device Setup - the mid-enrollment restart and second sign-in; Soft reboot leaves the app pending until the next restart while the ESP moves on. Confidence rises for Hard reboot and when a reboot was actually observed in the session.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -86,6 +86,31 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "{{reboot_return_code.appName}} asked for a restart",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{reboot_return_code.appId}}",
|
||||
"name": "{{reboot_return_code.appName}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Return code",
|
||||
"value": "{{reboot_return_code.exitCode}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Return code class",
|
||||
"value": "{{reboot_return_code.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Restarts",
|
||||
"value": "{{reboot_observed.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Correlates an active proxy configuration with app content download failures (Delivery Optimization timeout or download error), suggesting the proxy is blocking or throttling Intune content delivery.",
|
||||
"severity": "high",
|
||||
"category": "apps",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -67,6 +67,30 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "App downloads failed while a {{proxy_active.value}} proxy was configured",
|
||||
"subject": {
|
||||
"kind": "network",
|
||||
"key": "proxy",
|
||||
"name": "Proxy"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Proxy type",
|
||||
"value": "{{proxy_active.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Failed app",
|
||||
"value": "{{download_failure.appName}}"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{download_failure.errorCode}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"correlation",
|
||||
"apps",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when Windows Hello for Business provisioning did not start within the expected timeframe after ESP completion.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 60,
|
||||
"conditions": [
|
||||
@@ -30,5 +30,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Windows Hello provisioning did not start in time",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-hello",
|
||||
"name": "Windows Hello"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Timed out",
|
||||
"value": "{{hello_timeout.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["device", "hello", "timeout"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when memory usage stays above 90% across at least 3 performance snapshots during enrollment, indicating sustained memory pressure that can cause app installation failures or system instability.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -65,6 +65,22 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "Memory use was above 90% in {{sustained_high_memory.count}} snapshots",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "memory",
|
||||
"name": "Memory"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Snapshots above 90%",
|
||||
"value": "{{sustained_high_memory.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"memory",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices enrolling with an OS build that has reached end of support: Windows 11 22H2 (build 22621) or older, which includes every Windows 10 build. Maintenance note: raise the build cutoff to 22631 after Windows 11 23H2 reaches end of support on 2026-11-10.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "2.1.0",
|
||||
"version": "2.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -56,6 +56,26 @@
|
||||
"url": "https://learn.microsoft.com/en-us/lifecycle/products/windows-11-home-and-pro"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Windows build {{old_build.value}} is out of support",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "os",
|
||||
"name": "Windows version"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "OS",
|
||||
"value": "{{os_version.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Build",
|
||||
"value": "{{old_build.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"os-version",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects a Windows quality/cumulative update that FAILED to install or download during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 20 from the System log, 31 from the Operational channel). A cumulative update failing mid-OOBE is a known cause of enrollment errors and is otherwise invisible in the Intune console.",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -113,6 +113,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "A Windows update failed during setup: {{wu_update_title.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-update",
|
||||
"name": "Windows Update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{wu_hresult.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Symbol",
|
||||
"value": "{{wu_hresult_symbol.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Informational: a Windows quality/cumulative update installed during the OOBE / ESP enrollment window (WindowsUpdateClient EventID 19). Surfaces updates applied mid-enrollment — useful context when correlating enrollment duration, reboots, or later issues with a specific KB.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -61,6 +61,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "A Windows update installed during setup: {{wu_update_title.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "windows-update",
|
||||
"name": "Windows Update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Installed",
|
||||
"value": "{{wu_installed.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Informational: the OS build (CurrentBuild.UBR) changed across a reboot during enrollment — an update was installed, regardless of which servicing path delivered it. Deterministic corroboration that works even when the Windows Update event channel shows nothing (registry comparison across restart cannot be lost to timing or logging-path differences).",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -69,6 +69,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "Windows was updated from build {{previous_build.value}} to {{current_build.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "os",
|
||||
"name": "Windows version"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Build before",
|
||||
"value": "{{previous_build.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Build after",
|
||||
"value": "{{current_build.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The device's real-time clock deviates from NTP by more than two minutes. Microsoft documents that a clock off by several minutes causes TPM attestation errors (self-deploying/pre-provisioning) and ESP timeouts.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -17,7 +17,7 @@
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "clock_ahead",
|
||||
"signal": "clock_behind",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -26,7 +26,7 @@
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "clock_behind",
|
||||
"signal": "clock_ahead",
|
||||
"source": "event_data",
|
||||
"eventType": "ntp_time_check",
|
||||
"dataField": "offsetSeconds",
|
||||
@@ -59,6 +59,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "clock",
|
||||
"name": "System clock"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Clock offset (s)",
|
||||
"value": "{{clock_behind.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Clock offset (s)",
|
||||
"value": "{{clock_ahead.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"ntp",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "The device's clock changed by five or more minutes mid-session (clock jump) or ran five or more minutes off server time for the whole session (sustained offset), measured against the server receive frame of every uploaded event. Complements ANALYZE-DEV-007, which is a one-shot NTP check at agent startup and misses devices where NTP was unreachable or the clock changed later. IME-log-derived events are excluded from the measurement.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -57,6 +57,26 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "clock",
|
||||
"name": "System clock"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Clock jump (min)",
|
||||
"value": "{{clock_jump.jumpMinutes}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Clock offset (min)",
|
||||
"value": "{{sustained_offset.offsetMinutes}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"clock-skew",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device's battery dropped to 15% or less while enrolling on battery power (power_state_change threshold event from the agent's live power watcher). A device dying mid-enrollment leaves a half-provisioned state that typically needs a full reset; this rule surfaces the risk while there is still time to plug the device in. Uses a single required condition on the agent-stamped thresholdPercent field (the data field 'transition' on the event is unrelated to this rule's 'trigger' property).",
|
||||
"severity": "high",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -39,6 +39,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The battery fell below {{battery_threshold_15.value}}% during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Battery below (%)",
|
||||
"value": "{{battery_threshold_15.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Reported",
|
||||
"value": "{{battery_threshold_15.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device lost AC power and switched to battery while the enrollment was running (power_state_change event with transition ac_to_battery from the agent's live power watcher). Uses a single required condition so the match is pinned to one event instance; the event payload carries the battery percentage at the moment of the switch. The agent debounces power flapping (5s) so a brief unplug/replug does not produce this event.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -39,6 +39,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The device switched from AC to battery power during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Switched",
|
||||
"value": "{{switched_to_battery.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects that the device completed a sleep, hibernate or Modern Standby episode of at least 5 minutes while the enrollment was running (system_sleep_episode event from the agent's system timeline watcher, recorded retroactively at wake from the Windows event log). The watcher also backfills pre-agent episodes from the last 24 h for timeline context (backfilled=true); those ended before the enrollment started and are excluded via the same-event filter backfilled equals false — a separate condition could not do that, because each condition scans all events on its own and a pre-session hibernate would still satisfy the duration test. A single required condition keeps the match pinned to one event instance so the interpolated duration is the matched value. Short Modern Standby screen-off scenarios (under 60 s of real sleep) never emit the event, and this rule adds a 5-minute floor so brief lid-close moments do not fire it.",
|
||||
"severity": "info",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -42,6 +42,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The device went to sleep during setup",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "power",
|
||||
"name": "Power"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Asleep for",
|
||||
"value": "{{slept_during_enrollment.value}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Recorded",
|
||||
"value": "{{slept_during_enrollment.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"power",
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-013",
|
||||
"title": "OOBE Quality Update Failed",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE (Enrollment Status Page setting \"Install Windows quality updates\") reported a failed download or install and recorded no successful install afterwards — the page's own failure names on the oobe_update_page events. Setup usually continues, so the failure is otherwise invisible: the device finishes OOBE without the monthly security update. An OS build change across a restart (os_build_changed) vetoes the finding, and so does the agent's names_capped marker — once the page's event names were capped, a missing success name proves nothing. A downloadInstallFailureHelper entry alone is no failure: updates that installed fine log it too.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "update_failure",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "in",
|
||||
"value": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_install_success",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "os_build_unchanged",
|
||||
"source": "event_type",
|
||||
"eventType": "os_build_changed",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "update_page_failed",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "result",
|
||||
"operator": "equals",
|
||||
"value": "fail",
|
||||
"filterField": "page",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "OobeNDUP",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "update_skipped",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "regex",
|
||||
"value": "SkipDownloadInstallButtonClicked$",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
{
|
||||
"signal": "update_page_failed",
|
||||
"condition": "exists",
|
||||
"weight": 10
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE reported `{{update_failure}}`, and no successful install was recorded afterwards. When the download or install of the update fails, the page gives up and setup continues, so the enrollment itself can still succeed — but the device most likely finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. Selecting Skip on the page after the failure leads to the same result.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Find out why the update failed",
|
||||
"steps": [
|
||||
"Open the oobe_update_page events around the failure in the session timeline: the download progress before it shows how far the download got, and a Skip after it shows that someone chose to continue.",
|
||||
"Check the network path the device uses during OOBE for Windows Update and Delivery Optimization: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
|
||||
"Compare with other devices: the same failure on many devices of one site points at the network path, on one model at the device, and across the fleet at the update itself."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Make sure the device gets the update",
|
||||
"steps": [
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule.",
|
||||
"Where the OOBE quality update keeps failing for one network or device group, decide per Enrollment Status Page profile whether \"Install Windows quality updates\" should stay on."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The Windows quality update at the end of OOBE failed",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed step",
|
||||
"value": "{{update_failure.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-014",
|
||||
"title": "OOBE Quality Update Not Offered After an Earlier Failure",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE found no update to install although the device is out of date, on a device whose update page counts an earlier failed download or install (oobe_update_page: NDUPDownloadInstallPreviousFailureCount above 0, osUpdatesWithNDUPPI false, getDaysOutOfDateSucceeded above 0). No failure and no install success are recorded in this session — a failure in this session is ANALYZE-DEV-013 — and the agent's names_capped marker vetoes the finding, because a missing name proves nothing once the page's names were capped. The device finishes OOBE without the monthly security update.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "previous_failures",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "SdxWebAppCloudNDUP_initialize_NDUPDownloadInstallPreviousFailureCount",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "nothing_offered",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "equals",
|
||||
"value": "false",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "SdxWebAppCloudNDUP_updateManager_processUpdatesMetadata_osUpdatesWithNDUPPI",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "days_out_of_date",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_update_failure",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "in",
|
||||
"filterValue": "SdxWebAppCloudNDUP_processStatusChangeFromHandler_downloadFailedError,SdxWebAppCloudNDUP_processStatusChangeFromHandler_installFailedError,ExpeditedUpdate_commitExpeditionDownloadInstallAsyncFailure",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_install_success",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "installSucceededRebootRequired|commitExpeditionDownloadInstallAsyncSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE checked for updates and found none to install, although Windows reports the device as {{days_out_of_date}} days out of date. The page's failure counter for this device stands at {{previous_failures}}: an earlier download or install of an OOBE quality update on this device failed. The device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the earlier attempt",
|
||||
"steps": [
|
||||
"Look at the device's earlier enrollment sessions — the failed attempt and its ANALYZE-DEV-013 finding are usually there.",
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Reduce how far behind devices start",
|
||||
"steps": [
|
||||
"Devices that start OOBE many months behind need a long update during setup; a current OS image or factory build shortens it and gives a failed attempt less to repeat."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "No quality update was offered although the device is {{days_out_of_date.value}} days out of date",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Days out of date",
|
||||
"value": "{{days_out_of_date.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Earlier update failures",
|
||||
"value": "{{previous_failures.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-DEV-015",
|
||||
"title": "OOBE Update Check Ended Without a Result",
|
||||
"description": "Detects that the Windows quality update page at the end of OOBE started its Windows Update scan but ended without a scan result (oobe_update_page: startWUScanStartedSuccedded, an OobeNDUP stop and no ExpeditedUpdate_getUpdateResults name), on a device that is out of date and allowed to update. In the observed sessions the page ended two minutes after the scan started, on devices more than a year behind. The device finishes OOBE without the monthly security update. The agent's names_capped marker vetoes the finding: once the page's event names were capped, a missing scan result proves nothing.",
|
||||
"severity": "warning",
|
||||
"category": "device",
|
||||
"version": "1.1.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 75,
|
||||
"confidenceThreshold": 60,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "update_allowed",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "equals",
|
||||
"value": "true",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_isNDUPAllowedByCSPSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "scan_started",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "equals",
|
||||
"value": "ExpeditedUpdate_startWUScanStartedSuccedded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "no_scan_result",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "name",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "name",
|
||||
"filterOperator": "regex",
|
||||
"filterValue": "^ExpeditedUpdate_getUpdateResults",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "update_page_ended",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "equals",
|
||||
"value": "page_stopped",
|
||||
"filterField": "page",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "OobeNDUP",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "days_out_of_date",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "value",
|
||||
"operator": "gt",
|
||||
"value": "0",
|
||||
"filterField": "name",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "ExpeditedUpdate_getDaysOutOfDateSucceeded",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "page_names_complete",
|
||||
"source": "event_data",
|
||||
"eventType": "oobe_update_page",
|
||||
"dataField": "cxhEvent",
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"filterField": "cxhEvent",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "names_capped",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"explanation": "The Windows quality update page at the end of OOBE started a Windows Update scan, but no scan result arrived before the page ended. The device is {{days_out_of_date}} days out of date, so an update was due — the device finished OOBE without the monthly security update and installs it only later, through the Windows Update policy that applies after enrollment. The page waits only a short time for the scan (two minutes in the observed sessions): a scan that takes longer, or cannot reach Windows Update, ends the page without an update.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Check the path to Windows Update during OOBE",
|
||||
"steps": [
|
||||
"Verify that devices in OOBE reach the Windows Update service: proxy, TLS inspection and firewall rules for the Windows Update endpoints.",
|
||||
"Check whether an update policy applied during the Enrollment Status Page points the scan at another source, such as WSUS."
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "Shorten the scan",
|
||||
"steps": [
|
||||
"Devices far behind scan longer; a current OS image or factory build keeps the OOBE scan short.",
|
||||
"Verify the device's patch level after enrollment; the update ring assigned to the device installs the missing monthly security update on its normal schedule."
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Microsoft Learn: Install Windows quality updates during OOBE (Enrollment Status Page)",
|
||||
"url": "https://learn.microsoft.com/en-us/intune/device-enrollment/windows/setup-status-page#windows-monthly-security-update-release-details"
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The OOBE update check ended without a result",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "oobe-update",
|
||||
"name": "OOBE quality update"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Days out of date",
|
||||
"value": "{{days_out_of_date.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"device",
|
||||
"windows-update",
|
||||
"oobe",
|
||||
"quality-update"
|
||||
]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Autopilot enrollment has explicitly failed.",
|
||||
"severity": "critical",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 95,
|
||||
"conditions": [
|
||||
@@ -57,19 +57,40 @@
|
||||
"confidenceFactors": [
|
||||
{ "signal": "failed_app", "condition": "exists", "weight": 5 }
|
||||
],
|
||||
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts extracted from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP failed subcategory:** `{{failedSubcategory}}`\n- **ESP HRESULT:** `{{errorCode}}`\n\nWhen an ESP Apps-subcategory failure is paired with HRESULT `0x87D1041C`, the device-side install most likely completed but the Intune detection rule did not match afterwards — see the **App Detection Failure During ESP** rule (ANALYZE-APP-013) in this same analysis for the actionable root cause and remediation steps. Any field still shown as a literal `{{…}}` placeholder was simply not recorded for this session — for example, a non-ESP failure path has no ESP failed-subcategory or HRESULT.",
|
||||
"explanation": "The Autopilot enrollment has explicitly failed.\n\n**Key facts from this session's events:**\n\n- **Failure reason:** `{{reason}}`\n- **First failed app:** `{{appName}}`\n- **ESP step that failed:** `{{failedSubcategory}}`\n- **ESP error code:** `{{errorCode}}`\n\nA more specific finding in this analysis usually names the cause, such as an app, ESP or timeout finding.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate enrollment failure",
|
||||
"title": "Find the cause",
|
||||
"steps": [
|
||||
"Start with the more specific ANALYZE-APP-* / ANALYZE-ESP-* rules that fired alongside this one — they correlate specific HRESULTs to actionable causes (e.g. 0x87D1041C → detection rule mismatch in ANALYZE-APP-013)",
|
||||
"Read the `reason` field on the enrollment_failed event for the immediate trigger (e.g. esp_terminal_failure, hello_timeout, max_lifetime)",
|
||||
"If a HRESULT is present, look it up against the ESP error catalog (Microsoft Learn: Autopilot ESP error codes)",
|
||||
"Review other analyze rule results for correlated issues (disk space, network, app failures)",
|
||||
"Check the device event timeline for the sequence of events leading to failure",
|
||||
"Verify the Autopilot profile and ESP configuration in Intune"
|
||||
"Open the finding in this analysis that names the cause (an app, ESP or timeout finding) and follow its steps",
|
||||
"If no other finding fired, look up the ESP error code and check the failed ESP step in the session timeline",
|
||||
"Verify the Autopilot profile and the ESP settings in Intune"
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "The enrollment failed at the ESP step {{esp_failure_subcategory.value}} with {{esp_failure_errorcode.value}}",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Reason",
|
||||
"value": "{{enrollment_failed_reason.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{esp_failure_subcategory.value}}"
|
||||
},
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{esp_failure_errorcode.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "First failed app",
|
||||
"value": "{{failed_app.appName}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "failed", "critical"]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ENRL-002",
|
||||
"title": "Session Timed Out",
|
||||
"description": "Detects when the enrollment session was force-failed by the server-side inactivity timeout (no agent telemetry for the configured SessionTimeoutHours window) without ever completing or explicitly failing.",
|
||||
"description": "Detects when the server-side inactivity timeout closed the enrollment session (no agent telemetry for the configured SessionTimeoutHours window) before it ever completed or explicitly failed.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.2.0",
|
||||
"version": "1.4.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"preconditions": [
|
||||
@@ -20,6 +20,12 @@
|
||||
"eventType": "enrollment_failed",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when an explicit enrollment_failed exists — that path is already covered by ANALYZE-ENRL-001; avoid double-reporting the same session."
|
||||
},
|
||||
{
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"operator": "not_exists",
|
||||
"description": "Suppress when the platform blocked the session's device: the silence is the refused uploads, which ANALYZE-ENRL-006 explains — 'the agent died or the device went offline' would be wrong."
|
||||
}
|
||||
],
|
||||
"conditions": [
|
||||
@@ -58,7 +64,7 @@
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "The enrollment session was force-failed by the server-side maintenance sweep after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"explanation": "The server-side maintenance sweep closed this session after `{{timeoutHours}}` hour(s) of agent inactivity — the device stopped sending telemetry and never reported completion or an explicit failure.\n\nThis usually means the agent process died or the device went offline mid-enrollment (power off, network loss, bluescreen, reimage) rather than a clean enrollment failure. Because there is no terminal `enrollment_failed` event, the more specific app/ESP rules cannot pinpoint a cause — the evidence simply stops at the last event before the gap.\n\n**If the evidence shows the user desktop was reached and all tracked apps completed**, the device most likely got all its content but the ESP never signaled completion — on hybrid-joined devices the usual culprit is the user session never establishing Entra affinity (Account Setup re-running on every logon). Check whether the hybrid user-affinity finding also fired for this session.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Investigate the silent session",
|
||||
@@ -78,5 +84,21 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "No completion or failure arrived within {{timeoutHours.value}} hours",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Timeout (hours)",
|
||||
"value": "{{timeoutHours.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Desktop reached",
|
||||
"value": "{{desktop_was_reached.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "timeout", "stalled"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "A TPM-attestation-related error code documented in the Microsoft Autopilot known-issues list was observed (0x800705B4, 0x801C03EA, 0x81039001, 0x81039023, 0x81039024, 0x80070490). These block self-deploying and pre-provisioning deployments during 'Securing your hardware'. Observability: attestation precedes MDM enrollment, so in the normal deployment model (agent installed via IME after enrollment) these codes are rarely visible — the rule primarily serves bootstrap-token deployments.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -80,6 +80,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "TPM attestation failed with a known Autopilot error code",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "tpm",
|
||||
"name": "TPM attestation"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{tpm_hresult_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{tpm_hresult_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"tpm",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "An enrollment-blocking error code documented in the Microsoft Autopilot known-issues/troubleshooting pages was observed (0x80180014, 0x80180018, 0xC1036501, 0x801C03F3, 0x80070774). Each has a specific, documented cause and fix. Observability: a blocked MDM enrollment normally means no agent gets installed at all (IME install happens after enrollment) — this rule can realistically only fire for bootstrap-token deployments (pre-MDM reporting) or mixed retry scenarios.",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -78,6 +78,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "MDM enrollment was blocked with a known Autopilot error code",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "mdm-enrollment",
|
||||
"name": "MDM enrollment"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{enrl_hresult_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{enrl_hresult_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"mdm",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "A Microsoft Entra hybrid join Autopilot deployment hit error 0x80004005 — a documented build-dependent known issue, resolved by KB5065789 (25H2, build >= 26200.6725), KB5065426 (24H2, build >= 26100.6584), and KB5070312 (23H2, build >= 22631.6276).",
|
||||
"severity": "high",
|
||||
"category": "enrollment",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -103,6 +103,27 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Hybrid join hit 0x80004005, a known timeout fixed by Windows updates",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "hybrid-join",
|
||||
"name": "Hybrid join"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{e_fail_esp_failure.value}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "Error code",
|
||||
"value": "{{e_fail_esp_advisory.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"enrollment",
|
||||
"hybrid-join",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "../schema/analyze-rule.schema.json",
|
||||
"ruleId": "ANALYZE-ENRL-006",
|
||||
"title": "Monitoring Paused by a Protective Limit",
|
||||
"description": "Detects when the platform blocked the session's device — automatically after an unusually high event volume, or by the platform operator — so the session's telemetry stopped reaching Autopilot Monitor.",
|
||||
"severity": "warning",
|
||||
"category": "enrollment",
|
||||
"version": "1.0.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
"on_event:device_blocked"
|
||||
],
|
||||
"trigger": "single",
|
||||
"baseConfidence": 90,
|
||||
"confidenceThreshold": 50,
|
||||
"conditions": [
|
||||
{
|
||||
"signal": "device_blocked",
|
||||
"source": "event_type",
|
||||
"eventType": "device_blocked",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "event_count",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "eventCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "event_limit",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "threshold",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "blocked_until",
|
||||
"source": "event_data",
|
||||
"eventType": "device_blocked",
|
||||
"dataField": "blockedUntil",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"explanation": "Autopilot Monitor stopped accepting telemetry from this session. A protective limit keeps a single device that sends far more events than an enrollment normally does from distorting your fleet data or overloading the platform. When a session crosses it, the device's uploads are paused for a limited time; in severe cases the agent ends monitoring and removes itself. The platform operator can apply the same pause by hand.\n\nA pause does not affect the enrollment itself: the agent keeps running and only holds back its uploads, so Windows Autopilot and Intune carry on as usual. While the pause lasts, no events from this session reach Autopilot Monitor, so the timeline and the final status reflect the last state the agent reported before it. A paused session does not affect the device's next enrollment, which starts unblocked. When the agent ended monitoring, the device stays unmonitored until the end time shown on the session page, new enrollments included.",
|
||||
"remediation": [
|
||||
{
|
||||
"title": "Find what made the session so busy",
|
||||
"steps": [
|
||||
"Open the event timeline and look at which event type repeats most often before the pause — for example a network connection that drops and reconnects every few minutes, or a download that reports progress continuously",
|
||||
"Fix the underlying condition (for example a loose dock, cable or Wi-Fi), then re-enroll the device if you need a complete record of the enrollment",
|
||||
"If no event type stands out, report the session from its page so it can be looked at"
|
||||
]
|
||||
},
|
||||
{
|
||||
"title": "What happens next",
|
||||
"steps": [
|
||||
"A pause ends on its own at the time shown on the session page; an agent that is still running then resumes uploading",
|
||||
"After a pause, a new enrollment on this device starts unblocked",
|
||||
"After the agent ended monitoring, wait for the end time shown on the session page before re-enrolling if you want the new enrollment monitored"
|
||||
]
|
||||
}
|
||||
],
|
||||
"relatedDocs": [
|
||||
{
|
||||
"title": "Autopilot Monitor: Protective Limits",
|
||||
"url": "https://docs.autopilotmonitor.com/concepts/protective-limits"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "outcome",
|
||||
"summary": "Monitoring was paused by a protective limit",
|
||||
"facts": [
|
||||
{
|
||||
"label": "Paused at",
|
||||
"value": "{{device_blocked.timestamp}}",
|
||||
"format": "timestamp"
|
||||
},
|
||||
{
|
||||
"label": "Events in this session",
|
||||
"value": "{{event_count.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Event limit",
|
||||
"value": "{{event_limit.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Paused until",
|
||||
"value": "{{blocked_until.value}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["enrollment", "protective-limit", "blocked"]
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the DeviceSetup phase of the ESP took longer than 30 minutes, indicating a stuck or very slow blocking app. Confidence increases when the phase exceeds 60 minutes.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -47,6 +47,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Device setup took {{esp_stalled.durationFormatted}}",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "{{esp_stalled.phase}}",
|
||||
"name": "Device setup"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Duration",
|
||||
"value": "{{esp_stalled.durationSeconds}}",
|
||||
"format": "duration"
|
||||
},
|
||||
{
|
||||
"label": "Started",
|
||||
"value": "{{esp_stalled.phaseStartTimestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"blocking",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Certificates subcategory of the Enrollment Status Page reported a failure, even if the enrollment later appears to have succeeded. Users can click 'Continue anyway' on an ESP error, which masks the underlying certificate provisioning failure — this rule surfaces the transition so it stays visible.",
|
||||
"severity": "high",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -58,6 +58,22 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The ESP certificates step failed",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "Certificates",
|
||||
"name": "Certificates"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Failed at",
|
||||
"value": "{{certificates_subcategory_failed.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"certificates",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects an ESP terminal failure where the ESP gave up while a blocking app was still installing (the agent's own esp_apps_timeout verdict — no per-app HRESULT was reported) on a profile that allows 'Continue anyway'. The enrollment is reported as failed, but the user most likely dismissed the error screen and reached the desktop — a soft failure rather than an unusable device. An ESP failure that carries a specific error code (a failed installer, a detection mismatch) is a different case and stays with the rule that explains that code.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"baseConfidence": 80,
|
||||
"conditions": [
|
||||
@@ -81,5 +81,30 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The ESP gave up while {{appName.value}} was still installing ('Continue anyway' allowed)",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "{{appName.appId}}",
|
||||
"name": "{{appName.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP error",
|
||||
"value": "{{enrollment_failed.errorCode}}",
|
||||
"format": "errorCode"
|
||||
},
|
||||
{
|
||||
"label": "ESP time limit (min)",
|
||||
"value": "{{espSyncFailureTimeoutMinutes.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{failedSubcategory.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["esp", "timeout", "continue-anyway", "soft-failure", "enrollment"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Attributes an observed enrollment reboot to device-assigned MDM policies that Windows flagged as reboot-required. Fires ONLY when both facts are present in the session: at least one actually-observed reboot (system_reboot_detected) AND reboot-required policy flags (mdm_policy_reboot_required). Policy flags alone — e.g. from a user-policy sync during AccountSetup — force no reboot and stay advisory-silent.",
|
||||
"severity": "warning",
|
||||
"category": "esp",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -94,6 +94,31 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "A device policy forced a restart during setup: {{reboot_forcing_policy_uri.value}}",
|
||||
"subject": {
|
||||
"kind": "policy",
|
||||
"key": "{{reboot_forcing_policy_uri.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "First policy",
|
||||
"value": "{{reboot_forcing_policy_uri.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Policies",
|
||||
"value": "{{multiple_reboot_uris.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Restarts",
|
||||
"value": "{{reboot_observed.count}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"reboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects the recovery story: the ESP reported a terminal failure (typically DeviceSetup/Apps), the user most likely pressed 'Try again' on the failure page, the failed step re-ran and completed successfully. The earlier failure did not stick — the enrollment continued past it.",
|
||||
"severity": "info",
|
||||
"category": "esp",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -98,6 +98,34 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "context",
|
||||
"summary": "The ESP failed in the {{category.value}} phase, then recovered",
|
||||
"subject": {
|
||||
"kind": "esp",
|
||||
"key": "{{failedSubcategory.value}}",
|
||||
"name": "{{failedSubcategory.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "ESP phase",
|
||||
"value": "{{category.value}}"
|
||||
},
|
||||
{
|
||||
"label": "ESP step",
|
||||
"value": "{{failedSubcategory.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Likely apps",
|
||||
"value": "{{likelyCulpritApps.value}}"
|
||||
},
|
||||
{
|
||||
"label": "Recovered after (min)",
|
||||
"value": "{{minutesSinceFailure.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"esp",
|
||||
"recovery",
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"enrollment_end",
|
||||
"whiteglove_sealed"
|
||||
],
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"baseConfidence": 75,
|
||||
"conditions": [
|
||||
{
|
||||
@@ -55,6 +55,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The expected machine certificate was not found",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "machine-certificate",
|
||||
"name": "Machine certificate"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"identity",
|
||||
"certificates",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the LocalAdminAnalyzer flags unexpected local user accounts or profile directories on the device. This is a known Autopilot bypass technique where admin accounts are created before or during enrollment to gain persistent local admin access.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "1.3.0",
|
||||
"version": "1.4.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -41,6 +41,30 @@
|
||||
"operator": "equals",
|
||||
"value": "error",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_accounts",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.unexpected_accounts",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "unexpected_admin_members",
|
||||
"source": "event_data",
|
||||
"eventType": "local_admin_analysis",
|
||||
"dataField": "checks.unexpected_admin_members",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"filterField": "finding",
|
||||
"filterOperator": "equals",
|
||||
"filterValue": "unexpected_local_admins_detected",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -85,6 +109,26 @@
|
||||
}
|
||||
],
|
||||
"relatedDocs": [],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Unexpected local accounts: {{unexpected_accounts.value}}",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "{{unexpected_accounts.value}}"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Unexpected accounts",
|
||||
"value": "{{unexpected_accounts.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "In Administrators",
|
||||
"value": "{{unexpected_admin_members.value}}",
|
||||
"format": "code"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"identity",
|
||||
"security",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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.",
|
||||
"severity": "critical",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -72,6 +72,27 @@
|
||||
"url": "https://nvd.nist.gov/"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Installed software has {{critical_vulnerabilities.value}} critical vulnerabilities",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "vulnerabilities",
|
||||
"name": "Installed software"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Critical CVEs",
|
||||
"value": "{{critical_vulnerabilities.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Known exploited",
|
||||
"value": "{{kev_matches.value}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"vulnerability",
|
||||
"cve",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "On a hybrid-joined device the real user reached the desktop, but the Intune Management Extension never acquired a Microsoft Entra user token for that user within the wait window — repeatedly across reboots when the warning recurs. Without a user token the signed-in user has no Entra user affinity: user-targeted apps and policies cannot apply, Windows Hello for Business cannot provision, and with a user ESP the Account Setup page re-runs on every logon.",
|
||||
"severity": "high",
|
||||
"category": "identity",
|
||||
"version": "2.0.0",
|
||||
"version": "2.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"trigger": "correlation",
|
||||
@@ -44,6 +44,24 @@
|
||||
"operator": "not_exists",
|
||||
"value": "",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"signal": "token_failure_count",
|
||||
"source": "event_data",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"dataField": "tokenFailureCount",
|
||||
"operator": "exists",
|
||||
"value": "",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"signal": "token_failure_codes",
|
||||
"source": "event_data",
|
||||
"eventType": "entra_user_affinity_pending",
|
||||
"dataField": "tokenFailureCodes",
|
||||
"operator": "not_equals",
|
||||
"value": "",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"confidenceFactors": [
|
||||
@@ -96,5 +114,36 @@
|
||||
}
|
||||
],
|
||||
"markSessionAsFailedDefault": false,
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "The user reached the desktop, but Entra user affinity never formed",
|
||||
"subject": {
|
||||
"kind": "account",
|
||||
"key": "entra-user-affinity",
|
||||
"name": "Entra user affinity"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Token failures",
|
||||
"value": "{{token_failure_count.value}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Token error codes",
|
||||
"value": "{{token_failure_codes.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Reported in agent runs",
|
||||
"value": "{{user_affinity_pending.count}}",
|
||||
"format": "number"
|
||||
},
|
||||
{
|
||||
"label": "Desktop reached",
|
||||
"value": "{{user_desktop_reached.timestamp}}",
|
||||
"format": "timestamp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": ["identity", "hybrid-join", "entra-id", "user-affinity", "esp"]
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects diagnostics package uploads that were answered by a security appliance's HTML block page instead of the Autopilot Monitor backend. A forward proxy / secure web gateway (SWG) with SSL inspection is intercepting the upload endpoint, so exactly the sessions where diagnostics matter most (OnFailure uploads) are the ones losing them.",
|
||||
"severity": "warning",
|
||||
"category": "network",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"baseConfidence": 75,
|
||||
@@ -71,6 +71,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/autopilot/networking-requirements"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "A web security gateway blocked the diagnostics upload",
|
||||
"subject": {
|
||||
"kind": "network",
|
||||
"key": "security-gateway",
|
||||
"name": "Web security gateway"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"network",
|
||||
"proxy",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects when the Microsoft 365 Apps (Office Click-to-Run) install failed. The V2 OfficeInstallDetector watches the C2R client directly (registry, Delivery Optimization, process) and emits office_install_failed when the C2R INSTALL scenario records an error code. Completion is proven by the core Office binaries on disk; an install window that closes without that proof ends silently, never as a failure. This catches Office failures the deployment status hides: the Intune built-in app type is marked done once the configuration is delivered, and a Win32 or RealmJoin package once its installer exits, while C2R keeps installing in the background.",
|
||||
"severity": "warning",
|
||||
"category": "apps",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -52,6 +52,22 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "cause",
|
||||
"summary": "Microsoft 365 Apps failed to install",
|
||||
"subject": {
|
||||
"kind": "app",
|
||||
"key": "office-c2r",
|
||||
"name": "Microsoft 365 Apps"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{error_code_present.value}}",
|
||||
"format": "errorCode"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"apps",
|
||||
"office",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices where the Windows UEFI CA 2023 certificate has not been deployed to the Secure Boot DB. The 2011 certificates expired in June 2026 — affected devices no longer receive Secure Boot security updates until the 2023 certificate is deployed. Since v3 the agent reads the firmware db/KEK variables directly: when the firmware itself confirms the 2023 certificate, the rule is suppressed even if the Windows servicing status has not caught up yet.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "3.1.0",
|
||||
"version": "3.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -132,6 +132,35 @@
|
||||
"url": "https://support.microsoft.com/en-us/topic/kb5036210"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The Windows UEFI CA 2023 certificate is not deployed",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "secure-boot",
|
||||
"name": "Secure Boot"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "CA 2023 status",
|
||||
"value": "{{ca2023_not_updated.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Error",
|
||||
"value": "{{ca2023_error.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "CA 2023 in firmware DB",
|
||||
"value": "{{db_ca2023_firmware_missing.value}}"
|
||||
},
|
||||
{
|
||||
"label": "KEK 2023 in firmware",
|
||||
"value": "{{kek_2023_missing.value}}"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the AutoLogon analyzer reports a DefaultPassword value present in Winlogon. A plaintext AutoLogon password stored in the registry is readable by any local administrator and is a credential exposure — more severe than AutoLogon alone (the Sysinternals Autologon tool deliberately avoids this by storing the secret in the LSA instead).",
|
||||
"severity": "high",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -49,6 +49,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/sysinternals/downloads/autologon"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "A plaintext AutoLogon password is stored in the registry",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "autologon",
|
||||
"name": "AutoLogon"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"autologon",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -59,6 +59,21 @@
|
||||
"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",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects provisioning packages (PPKG) that are not on the built-in allow-list. The agent emits one or more provisioning_package_scan events whose artifacts[] arrays list every PPKG (registry package, .ppkg file, or Recovery\\Customizations residue; a large set is split across several events). This rule iterates that array across all of them and fires for any artifact whose identity does not match the allow-list regex. The default allow-list covers the Windows OS-inbox packages (Cosa, Power.EnergyEstimationEngine, Power.Settings, SecureStart) plus common OEM factory-preload families (Intel PPM power profiles, AMD Power profiles, Lenovo ThinkPad power/battery packages, Microsoft Surface defaults and carrier packages) so clean factory devices stay quiet. OEM imaging leftovers (e.g. USMT.PPKG, InstallOrder.ini) and generically named packages deliberately still alert — on a pure-Autopilot device they are worth a look. To allow your own vendor/recovery packages, enable the template rule ANALYZE-SEC-006, extend its allow-list, then disable this rule.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -38,6 +38,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{ppkg_not_allowlisted.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Unexpected packages",
|
||||
"value": "{{ppkg_not_allowlisted.matchCount}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Template version of ANALYZE-SEC-005 with a customizable allow-list. Iterates the provisioning_package_scan artifacts[] array and fires for any PPKG whose identity is not on YOUR allow-list regex. The placeholder ships with the same OS-inbox defaults as ANALYZE-SEC-005 — extend it with your own vendor/recovery packages. This rule is OFF by default: enable it, set your allow-list, then disable ANALYZE-SEC-005 so only packages outside your list alert.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "2.2.0",
|
||||
"version": "2.3.0",
|
||||
"enabled": false,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -45,6 +45,27 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Provisioning package not on the allow list: {{ppkg_not_allowlisted.value}}",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
},
|
||||
"facts": [
|
||||
{
|
||||
"label": "Package",
|
||||
"value": "{{ppkg_not_allowlisted.value}}",
|
||||
"format": "code"
|
||||
},
|
||||
{
|
||||
"label": "Unexpected packages",
|
||||
"value": "{{ppkg_not_allowlisted.matchCount}}",
|
||||
"format": "number"
|
||||
}
|
||||
]
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Fires when the provisioning-package scan hit a per-source collection cap (too many .ppkg files, registry packages, or Recovery\\Customizations residue files to capture them all). When that happens the allow-list rules (ANALYZE-SEC-005/006) could not evaluate every package, so a non-allow-listed package may have gone unseen. A device with that many provisioning packages is itself unusual and worth a manual look.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
"enrollment_end",
|
||||
@@ -36,6 +36,15 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "The provisioning package scan hit its capture limit",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "provisioning-packages",
|
||||
"name": "Provisioning packages"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"provisioning-package",
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"description": "Detects devices enrolling with Secure Boot turned off. Secure Boot is a firmware-level protection against boot-time malware and is a prerequisite for several Windows security features (VBS/Credential Guard, device health attestation) and common Intune compliance policies. Devices that support Secure Boot but have it disabled should be flagged during enrollment. Legacy BIOS devices (no Secure Boot capability) are excluded — the agent marks those with a note field when the SecureBoot registry state key is absent.",
|
||||
"severity": "warning",
|
||||
"category": "security",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"author": "Autopilot Monitor",
|
||||
"enabled": true,
|
||||
"evaluateOn": [
|
||||
@@ -82,6 +82,15 @@
|
||||
"url": "https://learn.microsoft.com/en-us/windows-hardware/manufacture/desktop/disabling-secure-boot"
|
||||
}
|
||||
],
|
||||
"presentation": {
|
||||
"role": "advisory",
|
||||
"summary": "Secure Boot is disabled",
|
||||
"subject": {
|
||||
"kind": "device",
|
||||
"key": "secure-boot",
|
||||
"name": "Secure Boot"
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"secureboot",
|
||||
|
||||
+1621
-155
File diff suppressed because it is too large
Load Diff
+22
-3
@@ -195,7 +195,7 @@
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -285,12 +285,12 @@
|
||||
{
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
@@ -487,6 +487,25 @@
|
||||
"description": "Captures the JSON policies list discovered by IME, used to build the app inventory.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-POST-INSTALL-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
|
||||
"action": "markPostInstallDetection",
|
||||
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-POST-REBOOT-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "markPostInstallDetection",
|
||||
"parameters": {
|
||||
"afterReboot": "true"
|
||||
},
|
||||
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
|
||||
"enabled": true
|
||||
},
|
||||
{
|
||||
"patternId": "IME-RESTART-STOP-PROCESSING",
|
||||
"category": "currentPhase",
|
||||
|
||||
@@ -292,6 +292,7 @@
|
||||
"app_install_completed",
|
||||
"app_install_failed",
|
||||
"app_install_skipped",
|
||||
"app_post_install_detection",
|
||||
"app_download_started",
|
||||
"app_tracking_summary",
|
||||
"download_progress",
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Detection for policy with id: (?<id>{GUID}) resulted in action status: Success and detection state: (?<detection>DetectedDifferentVersion|NotDetected|Detected)",
|
||||
"action": "captureDetectionResult",
|
||||
"description": "Captures the detection rule result after IME runs detection for an app. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"description": "Captures the result of every IME detection run for an app: the check before the install (NotDetected is what starts the install) and the check right after it. App events therefore carry the check before the install; IME's verdict after the install is the enforcement error 0x87D1041C on failure. 'Detected' means the detection rule matched (app is installed), 'NotDetected' means it did not, 'DetectedDifferentVersion' means a different version was found (supersedence / WinGet version mismatch). DetectedDifferentVersion must come first in the alternation so the unanchored regex does not mis-capture its 'Detected' prefix. Excludes [SimulatedDetectionActionHandler] which fires during applicability pre-checks.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-ERROR-REPORT",
|
||||
"category": "currentPhase",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:\"{1,2}(?<from>\\w+)\"{1,2}|null),\"{1,2}NewValue\"{1,2}:\"{1,2}(?<to>\\w+)\"{1,2}(?:\\},\"{1,2}EnforcementErrorCode\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:(?:-?\\d+|null),\"{1,2}NewValue\"{1,2}:(?<errorCode>-?\\d+))?",
|
||||
"action": "updateStateError",
|
||||
"parameters": {
|
||||
"checkTo": "true"
|
||||
},
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both.",
|
||||
"description": "Captures enforcement state transitions via ReportingManager. Only triggers error when 'to' value indicates an error state. OldValue may be the unquoted JSON literal null on the first-ever write of a reporting state — transitions straight from the initial state (e.g. first write directly to an error state) would otherwise be invisible (IME 1.97 audit 2026-08-17). Quote-flexible since 2026-08-17: IME 1.104 serializes the report-delta JSON with single quotes ({\"Key\":...) where older versions logged doubled quotes ({\"\"Key\"\":...) - every literal quote in the delta is matched as \"{1,2} to cover both. The optional errorCode group takes EnforcementErrorCode.NewValue, which IME logs as a signed decimal right after the state object (e.g. -2016345060 = 0x87D1041C: IME's check after the install did not find the app); the code reaches app_install_failed only when this line is the app's first move to Error.",
|
||||
"enabled": true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POST-INSTALL-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[DetectionActionHandler\\] Policy with id: {GUID} is expected to have enforcement state: Detected\\.",
|
||||
"action": "markPostInstallDetection",
|
||||
"description": "Marks IME's check right after an install. IME logs this line only for the app it has just installed, before that check's result line; the agent reports that result (IME-DETECTION-RESULT) as app_post_install_detection and keeps detectionResult as the check before the install. Other apps of the same dependency chain are checked in the same run without this line. A failed check is IME's error 0x87D1041C (IME-ERROR-REPORT).",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../schema/ime-log-pattern.schema.json",
|
||||
"patternId": "IME-POST-REBOOT-DETECTION",
|
||||
"category": "always",
|
||||
"pattern": "^\\[Win32App\\]\\[ReportingManager\\] Execution state for app with id: {GUID} has been updated\\. Report delta: \\{\"{1,2}EnforcementState\"{1,2}:\\{\"{1,2}OldValue\"{1,2}:\"{1,2}InProgressPendingReboot\"{1,2},\"{1,2}NewValue\"{1,2}:\"{1,2}Success\"{1,2}",
|
||||
"action": "markPostInstallDetection",
|
||||
"parameters": {
|
||||
"afterReboot": "true"
|
||||
},
|
||||
"description": "Marks IME's check after the restart an installer requested. After an exit code mapped to SoftReboot or HardReboot the check right after the install decides nothing; after the restart IME moves the app from InProgressPendingReboot to Success (held back like after a clean install) and checks again without the IME-POST-INSTALL-DETECTION line. That check's result is reported as app_post_install_detection with afterReboot=true. Quote-flexible like IME-ENFORCEMENT-SUCCESS.",
|
||||
"enabled": true
|
||||
}
|
||||
@@ -133,6 +133,7 @@
|
||||
"items": { "$ref": "#/$defs/relatedDoc" },
|
||||
"default": []
|
||||
},
|
||||
"presentation": { "$ref": "#/$defs/rulePresentation" },
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"description": "Tags for filtering and categorization.",
|
||||
@@ -352,6 +353,83 @@
|
||||
"required": ["title", "url"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"rulePresentation": {
|
||||
"type": "object",
|
||||
"description": "How a finding of this rule is shown: its role, a one-line summary, what it is about and its key facts. Every string is a {{token}} template resolved from the finding's evidence when it is rendered; a fact whose value was not recorded is left out.",
|
||||
"properties": {
|
||||
"role": {
|
||||
"type": "string",
|
||||
"enum": ["outcome", "cause", "context", "advisory"],
|
||||
"description": "outcome: restates the session outcome (shown in the verdict, not as a finding row). cause: can explain why a session failed or is stuck. context: a circumstance such as standby, a restart or battery power. advisory: hygiene or security without bearing on the outcome."
|
||||
},
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 160,
|
||||
"description": "One line for the finding row, for example \"{{install_retry_loop.appName}} was started {{install_retry_loop.count}} times\". When a token was not recorded, the first sentence of the explanation is shown instead."
|
||||
},
|
||||
"subject": { "$ref": "#/$defs/ruleSubject" },
|
||||
"facts": {
|
||||
"type": "array",
|
||||
"description": "Key facts shown as label/value pairs, in this order.",
|
||||
"maxItems": 6,
|
||||
"items": { "$ref": "#/$defs/ruleFact" }
|
||||
}
|
||||
},
|
||||
"required": ["role"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ruleSubject": {
|
||||
"type": "object",
|
||||
"description": "What the finding is about. Findings with the same kind and resolved key are grouped, for example every finding about one app. Use qualified tokens of one signal for key and name, such as {{failed_app.appId}} and {{failed_app.appName}}, so both come from the same event.",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"type": "string",
|
||||
"enum": ["app", "policy", "account", "script", "device", "network", "esp", "session"],
|
||||
"description": "The kind of thing the key identifies."
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the grouping key."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the display name; the key is shown when it is missing."
|
||||
}
|
||||
},
|
||||
"required": ["kind", "key"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"ruleFact": {
|
||||
"type": "object",
|
||||
"description": "One key fact of a finding.",
|
||||
"properties": {
|
||||
"label": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 40,
|
||||
"description": "Short label, for example \"Install starts\"."
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "Template of the value, for example {{install_retry_loop.count}}."
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["text", "code", "errorCode", "duration", "number", "timestamp"],
|
||||
"description": "How the value is shown. errorCode adds the error-code catalog meaning, duration reads seconds, timestamp an ISO 8601 time.",
|
||||
"default": "text"
|
||||
}
|
||||
},
|
||||
"required": ["label", "value"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"templateVariable": {
|
||||
"type": "object",
|
||||
"description": "A variable in a rule condition that must be customized per-tenant before the rule can be used.",
|
||||
|
||||
@@ -82,7 +82,8 @@
|
||||
"captureAppTypeMsi",
|
||||
"captureAttemptNumber",
|
||||
"captureDetectionResult",
|
||||
"captureExitCodeClass"
|
||||
"captureExitCodeClass",
|
||||
"markPostInstallDetection"
|
||||
]
|
||||
},
|
||||
"parameters": {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
# ajv-cli declares ts-node as a peer dependency it never needs for JSON schemas; do not
|
||||
# pull ts-node + typescript into a job that pushes to main.
|
||||
legacy-peer-deps=true
|
||||
@@ -86,12 +86,12 @@ for (const config of configs) {
|
||||
// ── Guardrails generation ───────────────────────────────────────────────────
|
||||
|
||||
const guardrailsPath = path.join(rulesRoot, 'guardrails.json');
|
||||
// NOTE: consumers import from "@/utils/guardrails.generated" (see guardValidation.ts
|
||||
// NOTE: consumers import from "@/lib/generated/guardrails.generated" (see guardValidation.ts
|
||||
// and SectionGatherRules.tsx). The web re-org (commit a3e8e044) moved this file to
|
||||
// utils/ but left this output path pointing at the now-orphaned lib/ copy, which
|
||||
// silently froze the live allowlist. Keep this in sync with the import path.
|
||||
const guardrailsOutput = path.resolve(
|
||||
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'utils', 'guardrails.generated.ts'
|
||||
rulesRoot, '..', 'src', 'Web', 'autopilot-monitor-web', 'lib', 'generated', 'guardrails.generated.ts'
|
||||
);
|
||||
|
||||
if (fs.existsSync(guardrailsPath)) {
|
||||
|
||||
Generated
+4
-229
@@ -6,7 +6,7 @@
|
||||
"": {
|
||||
"name": "autopilot-monitor-rules-tooling",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
}
|
||||
},
|
||||
@@ -27,33 +27,6 @@
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-cli": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv-cli/-/ajv-cli-5.0.0.tgz",
|
||||
"integrity": "sha512-LY4m6dUv44HTyhV+u2z5uX4EhPYTM38Iv1jdgDJJJCyOOuqB8KtZEGjPZ2T+sh5ZIJrXUfgErYx/j3gLd3+PlQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0",
|
||||
"fast-json-patch": "^2.0.0",
|
||||
"glob": "^7.1.0",
|
||||
"js-yaml": "^3.14.0",
|
||||
"json-schema-migrate": "^2.0.0",
|
||||
"json5": "^2.1.3",
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"ajv": "dist/index.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"ts-node": ">=9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"ts-node": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/ajv-formats": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||
@@ -72,55 +45,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"sprintf-js": "~1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
|
||||
"integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esprima": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
|
||||
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"bin": {
|
||||
"esparse": "bin/esparse.js",
|
||||
"esvalidate": "bin/esvalidate.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@@ -128,17 +52,10 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-json-patch": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
|
||||
"integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
|
||||
"integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -152,78 +69,6 @@
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fs.realpath": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
|
||||
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
|
||||
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.1.1",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/inflight": {
|
||||
"version": "1.0.6",
|
||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
|
||||
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"once": "^1.3.0",
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/js-yaml": {
|
||||
"version": "3.15.2",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
|
||||
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^1.0.7",
|
||||
"esprima": "^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"js-yaml": "bin/js-yaml.js"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-migrate": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-migrate/-/json-schema-migrate-2.0.0.tgz",
|
||||
"integrity": "sha512-r38SVTtojDRp4eD6WsCqiE0eNDt4v1WalBXb9cyZYw9ai5cGtBwzRNWjHzJl38w6TxFkXAIA7h+fyX3tnrAFhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ajv": "^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
@@ -231,62 +76,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
|
||||
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/once": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
|
||||
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"wrappy": "1"
|
||||
}
|
||||
},
|
||||
"node_modules/path-is-absolute": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
|
||||
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
@@ -296,20 +85,6 @@
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sprintf-js": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/wrappy": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
"private": true,
|
||||
"description": "Validator tooling for rules/ - combine-rules.yml installs from the lockfile so nothing unpinned reaches a job that pushes to main.",
|
||||
"devDependencies": {
|
||||
"ajv-cli": "5.0.0",
|
||||
"ajv": "8.20.0",
|
||||
"ajv-formats": "3.0.1"
|
||||
},
|
||||
"overrides": {
|
||||
"fast-json-patch": "3.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Validates every rule file and rules/guardrails.json against its JSON Schema
|
||||
* (draft 2020-12). combine-rules.yml runs this before combine.js; any invalid
|
||||
* file fails the run with exit code 1.
|
||||
*
|
||||
* Run: node rules/scripts/validate.js (after `npm ci --ignore-scripts` in rules/scripts)
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const Ajv2020 = require('ajv/dist/2020');
|
||||
const addFormats = require('ajv-formats');
|
||||
|
||||
const rulesRoot = path.resolve(__dirname, '..');
|
||||
|
||||
const jsonFilesIn = (dir) =>
|
||||
fs.readdirSync(path.join(rulesRoot, dir)).filter(f => f.endsWith('.json')).sort().map(f => `${dir}/${f}`);
|
||||
|
||||
const sets = [
|
||||
{ schema: 'gather-rule.schema.json', files: jsonFilesIn('gather') },
|
||||
{ schema: 'analyze-rule.schema.json', files: jsonFilesIn('analyze') },
|
||||
{ schema: 'ime-log-pattern.schema.json', files: jsonFilesIn('ime-log-patterns') },
|
||||
// guardrails.json values are baked verbatim into generated TypeScript that is
|
||||
// auto-committed to main and shipped to the portal - the schema forbids control
|
||||
// and line-terminator characters so a "data" PR can never become code.
|
||||
{ schema: 'guardrails.schema.json', files: ['guardrails.json'] }
|
||||
];
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true });
|
||||
addFormats(ajv);
|
||||
|
||||
let invalid = 0;
|
||||
|
||||
for (const set of sets) {
|
||||
const validate = ajv.compile(JSON.parse(fs.readFileSync(path.join(rulesRoot, 'schema', set.schema), 'utf8')));
|
||||
|
||||
for (const file of set.files) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(fs.readFileSync(path.join(rulesRoot, file), 'utf8'));
|
||||
} catch (err) {
|
||||
invalid++;
|
||||
console.error(`INVALID rules/${file}: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!validate(data)) {
|
||||
invalid++;
|
||||
console.error(`INVALID rules/${file}`);
|
||||
for (const e of validate.errors) {
|
||||
console.error(` ${e.instancePath || '(root)'} ${e.message} ${JSON.stringify(e.params)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${set.schema}: ${set.files.length} files checked`);
|
||||
}
|
||||
|
||||
if (invalid > 0) {
|
||||
console.error(`ERROR: ${invalid} file(s) failed schema validation`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,991 @@
|
||||
# Integration tests (Pester 5+) for the bootstrap chain: the real loader and installer, each
|
||||
# started the way Intune and the bootstrap MSI start them -- powershell.exe -File, in a process
|
||||
# of its own -- against a scratch ProgramData and TEMP. Loader 1.2 and the MSI 1.0.x runner run
|
||||
# from TestData, frozen from git: devices keep running them until customers replace them.
|
||||
#
|
||||
# What these tests do not cover is swapped out by global aliases, which win over cmdlets and
|
||||
# over functions a script defines: downloads (served from a scratch folder), signatures, the
|
||||
# catalog check, the guard decision, the deployment marker, the agent task, the agent's
|
||||
# --install (a real program start from PowerShell's location, with the agent's work left out)
|
||||
# and sleeps. Every swap writes a trace line, and the tests check the lines they rely on, so a
|
||||
# swap that stops working turns a test red instead of reaching the real machine.
|
||||
#
|
||||
# Run elevated (CI also runs it as SYSTEM), the folder permissions are the real ones. Without
|
||||
# elevation the test account may not make Administrators the owner, so the protected DACL is
|
||||
# swapped for one without that owner change, and folders the account owns count as ours.
|
||||
# Entries whose name starts with "foreign" count as another account's.
|
||||
#
|
||||
# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI).
|
||||
|
||||
BeforeAll {
|
||||
$script:repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path
|
||||
$script:loaderPath = Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1'
|
||||
$script:installerPath = Join-Path $PSScriptRoot 'Install-AutopilotMonitor.ps1'
|
||||
$script:loader12Path = Join-Path $PSScriptRoot 'TestData\Start-AutopilotMonitor-1.2.ps1'
|
||||
$script:msiRunnerPath = Join-Path $PSScriptRoot 'TestData\Invoke-BootstrapDownload-1.0.ps1'
|
||||
# The backend's pin of the generated OOBE script, test values filled in.
|
||||
$script:oobeScriptPath = Join-Path $script:repoRoot 'src\Backend\AutopilotMonitor.Functions.Tests\TestData\oobe-bootstrap-golden.ps1'
|
||||
$script:powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe'
|
||||
$script:isElevated = (New-Object System.Security.Principal.WindowsPrincipal([System.Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole([System.Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
$script:payloadFiles = @('AutopilotMonitor.Agent.Core.dll', 'AutopilotMonitor.Agent.cat', 'AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.exe.config')
|
||||
|
||||
# The agent package: text files under the agent's names. --install is swapped out, so
|
||||
# nothing in it ever runs.
|
||||
$payload = Join-Path $TestDrive 'payload'
|
||||
New-Item -ItemType Directory -Path $payload | Out-Null
|
||||
foreach ($name in $script:payloadFiles) { Set-Content -LiteralPath (Join-Path $payload $name) -Value "stub $name" }
|
||||
$script:agentZip = Join-Path $TestDrive 'AutopilotMonitor-Agent.zip'
|
||||
Compress-Archive -Path (Join-Path $payload '*') -DestinationPath $script:agentZip
|
||||
$script:agentZipSha256 = (Get-FileHash -LiteralPath $script:agentZip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
$script:wrapperText = @'
|
||||
$global:HarnessDir = $PSScriptRoot
|
||||
$global:HarnessOptions = [System.IO.File]::ReadAllText((Join-Path $PSScriptRoot 'options.json')) | ConvertFrom-Json
|
||||
|
||||
function global:Write-HarnessTrace {
|
||||
param([string]$Name, [string]$Detail)
|
||||
[System.IO.File]::AppendAllText((Join-Path $global:HarnessDir 'trace.txt'), "$Name|$Detail`r`n")
|
||||
}
|
||||
|
||||
function global:Get-HarnessServerFile {
|
||||
param([string]$Uri)
|
||||
$source = Join-Path $global:HarnessOptions.Server ($Uri.Substring($Uri.LastIndexOf('/') + 1))
|
||||
if (-not [System.IO.File]::Exists($source)) { throw "The remote server returned an error: (404) Not Found. ($Uri)" }
|
||||
return $source
|
||||
}
|
||||
|
||||
function global:HarnessInvokeWebRequest {
|
||||
[CmdletBinding()]
|
||||
param([string]$Uri, [string]$OutFile, [switch]$UseBasicParsing, [int]$TimeoutSec, [string]$Method)
|
||||
Write-HarnessTrace 'Invoke-WebRequest' $Uri
|
||||
[System.IO.File]::Copy((Get-HarnessServerFile $Uri), $OutFile, $true)
|
||||
}
|
||||
|
||||
function global:HarnessInvokeRestMethod {
|
||||
[CmdletBinding()]
|
||||
param([string]$Uri, [switch]$UseBasicParsing, [int]$TimeoutSec)
|
||||
Write-HarnessTrace 'Invoke-RestMethod' $Uri
|
||||
return ([System.IO.File]::ReadAllText((Get-HarnessServerFile $Uri)) | ConvertFrom-Json)
|
||||
}
|
||||
|
||||
function global:HarnessGetAuthenticodeSignature {
|
||||
[CmdletBinding()]
|
||||
param([string]$FilePath, [byte[]]$Content, [string[]]$SourcePathOrExtension)
|
||||
Write-HarnessTrace 'Get-AuthenticodeSignature' $(if ($Content) { "content:$($Content.Length)" } else { $FilePath })
|
||||
return [pscustomobject]@{
|
||||
Status = $global:HarnessOptions.SignatureStatus
|
||||
SignerCertificate = [pscustomobject]@{ Subject = $global:HarnessOptions.SignerSubject; Thumbprint = 'HARNESS' }
|
||||
}
|
||||
}
|
||||
|
||||
function global:HarnessTestFileCatalog {
|
||||
[CmdletBinding()]
|
||||
param([string]$Path, [string]$CatalogFilePath, [string[]]$FilesToSkip, [switch]$Detailed)
|
||||
Write-HarnessTrace 'Test-FileCatalog' $Path
|
||||
return [pscustomobject]@{
|
||||
Status = $(if ($global:HarnessOptions.CatalogValid) { 'Valid' } else { 'ValidationFailed' })
|
||||
Signature = [pscustomobject]@{ Status = 'Valid'; SignerCertificate = [pscustomobject]@{ Subject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } }
|
||||
CatalogItems = @{}
|
||||
PathItems = @{}
|
||||
}
|
||||
}
|
||||
|
||||
function global:HarnessGetBootstrapDecision {
|
||||
param([int]$MaxBootstrapWindowHours, [int]$OobeProfileMaxAgeMinutes)
|
||||
Write-HarnessTrace 'Get-BootstrapDecision' "MaxBootstrapWindowHours=$MaxBootstrapWindowHours"
|
||||
$install = [bool]$global:HarnessOptions.Install
|
||||
return [pscustomobject]@{ Install = $install; ReasonCode = $(if ($install) { $null } else { 'ProfilesFound' }); RelaxActive = $false }
|
||||
}
|
||||
|
||||
function global:HarnessGetRegistryValue64 {
|
||||
param([string]$SubKey, [string]$Name)
|
||||
Write-HarnessTrace 'Get-RegistryValue64' "$SubKey\$Name"
|
||||
if ($SubKey -eq 'SOFTWARE\AutopilotMonitor' -and $Name -eq 'Deployed') { return $global:HarnessOptions.Marker }
|
||||
return $null
|
||||
}
|
||||
|
||||
function global:HarnessGetScheduledTask {
|
||||
[CmdletBinding()]
|
||||
param([string]$TaskName)
|
||||
Write-HarnessTrace 'Get-ScheduledTask' $TaskName
|
||||
if ($global:HarnessOptions.TaskPresent) { return [pscustomobject]@{ TaskName = $TaskName } }
|
||||
throw (New-Object System.Management.Automation.ErrorRecord((New-Object System.Exception "No MSFT_ScheduledTask objects found with property 'TaskName' equal to '$TaskName'."), 'CmdletizationQuery_NotFound_TaskName,Get-ScheduledTask', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $TaskName))
|
||||
}
|
||||
|
||||
function global:HarnessInvokeAgentInstallMode {
|
||||
param([string]$ExePath, [string[]]$Arguments = @('--install'))
|
||||
Write-HarnessTrace 'Invoke-AgentInstallMode' ('{0} {1} exists={2}' -f $ExePath, ($Arguments -join ' '), [System.IO.File]::Exists($ExePath))
|
||||
# A real program start from PowerShell's own location, as the agent start is: a location
|
||||
# that no longer exists fails here with error 267, as it did on the MSI channel. With
|
||||
# AgentStartFails the stub payload itself is started, a text file that never runs.
|
||||
if ($global:HarnessOptions.AgentStartFails) {
|
||||
& $ExePath @Arguments | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
& "$env:SystemRoot\System32\cmd.exe" /c "exit $([int]$global:HarnessOptions.InstallExitCode)" | Out-Host
|
||||
return $LASTEXITCODE
|
||||
}
|
||||
|
||||
function global:HarnessGetProcess {
|
||||
[CmdletBinding()]
|
||||
param([string[]]$Name)
|
||||
if ($Name -contains 'AutopilotMonitor.Agent') {
|
||||
Write-HarnessTrace 'Get-Process' 'AutopilotMonitor.Agent'
|
||||
return [pscustomobject]@{ Id = 4242; StartTime = Get-Date }
|
||||
}
|
||||
return Microsoft.PowerShell.Management\Get-Process @PSBoundParameters
|
||||
}
|
||||
|
||||
function global:HarnessStartSleep {
|
||||
[CmdletBinding()]
|
||||
param([int]$Seconds, [int]$Milliseconds)
|
||||
Write-HarnessTrace 'Start-Sleep' "$Seconds s $Milliseconds ms"
|
||||
}
|
||||
|
||||
function global:HarnessTestIsElevated {
|
||||
Write-HarnessTrace 'Test-IsElevated' "$($global:HarnessOptions.Elevated)"
|
||||
return [bool]$global:HarnessOptions.Elevated
|
||||
}
|
||||
|
||||
# The protected DACL without the owner change a standard account may not make.
|
||||
function global:HarnessNewProtectedDirectorySecurity {
|
||||
$security = New-Object System.Security.AccessControl.DirectorySecurity
|
||||
$security.SetAccessRuleProtection($true, $false)
|
||||
$inherit = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
||||
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
||||
foreach ($sid in @([System.Security.Principal.WindowsIdentity]::GetCurrent().User, (New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')))) {
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inherit, $propagation, 'Allow')))
|
||||
}
|
||||
$security.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule((New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-545')), 'ReadAndExecute', $inherit, $propagation, 'Allow')))
|
||||
return $security
|
||||
}
|
||||
|
||||
function global:HarnessTestIsOwnedByUs {
|
||||
param([string]$Path)
|
||||
if ([System.IO.Path]::GetFileName($Path) -like 'foreign*') { return $false }
|
||||
try {
|
||||
$owner = (Get-Item -LiteralPath $Path -Force -ErrorAction Stop).GetAccessControl([System.Security.AccessControl.AccessControlSections]::Owner).GetOwner([System.Security.Principal.SecurityIdentifier])
|
||||
return ($owner.Value -eq 'S-1-5-18' -or $owner.Value -eq 'S-1-5-32-544' -or $owner -eq [System.Security.Principal.WindowsIdentity]::GetCurrent().User)
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
$swaps = @{
|
||||
'Invoke-WebRequest' = 'HarnessInvokeWebRequest'
|
||||
'Invoke-RestMethod' = 'HarnessInvokeRestMethod'
|
||||
'Get-AuthenticodeSignature' = 'HarnessGetAuthenticodeSignature'
|
||||
'Test-FileCatalog' = 'HarnessTestFileCatalog'
|
||||
'Get-BootstrapDecision' = 'HarnessGetBootstrapDecision'
|
||||
'Get-RegistryValue64' = 'HarnessGetRegistryValue64'
|
||||
'Get-ScheduledTask' = 'HarnessGetScheduledTask'
|
||||
'Invoke-AgentInstallMode' = 'HarnessInvokeAgentInstallMode'
|
||||
'Get-Process' = 'HarnessGetProcess'
|
||||
'Start-Sleep' = 'HarnessStartSleep'
|
||||
'Test-IsOwnedByUs' = 'HarnessTestIsOwnedByUs'
|
||||
}
|
||||
if ($global:HarnessOptions.SwapElevation) { $swaps['Test-IsElevated'] = 'HarnessTestIsElevated' }
|
||||
if ($global:HarnessOptions.SwapDacl) { $swaps['New-ProtectedDirectorySecurity'] = 'HarnessNewProtectedDirectorySecurity' }
|
||||
foreach ($name in $swaps.Keys) { Set-Alias -Name $name -Value $swaps[$name] -Scope Global -Force }
|
||||
|
||||
# The bootstrap MSI's action is a -Command line; the OOBE script arrives as irm | iex, which
|
||||
# ignores #Requires.
|
||||
if ($global:HarnessOptions.CommandText) {
|
||||
Invoke-Expression $global:HarnessOptions.CommandText
|
||||
} elseif ($global:HarnessOptions.Iex) {
|
||||
Invoke-Expression ([System.IO.File]::ReadAllText($global:HarnessOptions.Script))
|
||||
} else {
|
||||
& $global:HarnessOptions.Script @args
|
||||
}
|
||||
exit $LASTEXITCODE
|
||||
'@
|
||||
|
||||
# A ZIP with exactly these entry names.
|
||||
function New-ZipWithEntries {
|
||||
param([string]$Path, [string[]]$Names)
|
||||
Add-Type -AssemblyName System.IO.Compression
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$archive = [System.IO.Compression.ZipFile]::Open($Path, [System.IO.Compression.ZipArchiveMode]::Create)
|
||||
try {
|
||||
foreach ($name in $Names) {
|
||||
$writer = New-Object System.IO.StreamWriter($archive.CreateEntry($name).Open())
|
||||
try { $writer.Write("content of $name") } finally { $writer.Dispose() }
|
||||
}
|
||||
} finally { $archive.Dispose() }
|
||||
}
|
||||
|
||||
# Serves a package with one entry that points outside the payload folder, with the
|
||||
# matching manifest hash, under the stable names.
|
||||
function Set-EscapingPackage {
|
||||
param($Scenario)
|
||||
$zip = Join-Path $Scenario.Server 'AutopilotMonitor-Agent.zip'
|
||||
Remove-Item -LiteralPath $zip
|
||||
New-ZipWithEntries -Path $zip -Names @('AutopilotMonitor.Agent.exe', 'AutopilotMonitor.Agent.cat', '../../../outside.txt')
|
||||
$sha = (Get-FileHash -LiteralPath $zip -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Set-Content -LiteralPath (Join-Path $Scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $sha + '"}')
|
||||
}
|
||||
|
||||
function New-Scenario {
|
||||
$root = Join-Path $TestDrive ('s' + [guid]::NewGuid().ToString('N').Substring(0, 7))
|
||||
$scenario = [pscustomobject]@{
|
||||
Root = $root
|
||||
ProgramData = Join-Path $root 'ProgramData'
|
||||
Base = Join-Path $root 'ProgramData\AutopilotMonitor'
|
||||
ProgramFiles = Join-Path $root 'ProgramFiles'
|
||||
Temp = Join-Path $root 'Temp'
|
||||
Server = Join-Path $root 'Server'
|
||||
Outside = Join-Path $root 'Outside'
|
||||
Ime = Join-Path $root 'Ime'
|
||||
}
|
||||
foreach ($dir in @($scenario.ProgramData, $scenario.Temp, $scenario.Server, $scenario.Outside, $scenario.Ime)) {
|
||||
New-Item -ItemType Directory -Path $dir -Force | Out-Null
|
||||
}
|
||||
Copy-Item -LiteralPath $script:installerPath -Destination (Join-Path $scenario.Server 'Install-AutopilotMonitor.ps1')
|
||||
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $scenario.Server 'AutopilotMonitor-Agent.zip')
|
||||
Set-Content -LiteralPath (Join-Path $scenario.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
return $scenario
|
||||
}
|
||||
|
||||
function Invoke-Scenario {
|
||||
param(
|
||||
$Scenario,
|
||||
[string]$Script,
|
||||
[string[]]$Arguments = @(),
|
||||
[string]$WorkingDirectory,
|
||||
[hashtable]$Options = @{}
|
||||
)
|
||||
$settings = @{
|
||||
Script = $Script
|
||||
Server = $Scenario.Server
|
||||
Install = $true
|
||||
Marker = $null
|
||||
TaskPresent = $false
|
||||
InstallExitCode = 0
|
||||
SignatureStatus = 'Valid'
|
||||
SignerSubject = 'CN=glueckkanja AG, O=glueckkanja AG, C=DE'
|
||||
CatalogValid = $true
|
||||
Elevated = $true
|
||||
Iex = $false
|
||||
CommandText = $null
|
||||
AgentStartFails = $false
|
||||
}
|
||||
foreach ($key in $Options.Keys) { $settings[$key] = $Options[$key] }
|
||||
$script:promiseScenario = $Scenario
|
||||
$settings.SwapElevation = ([bool]$settings.Elevated -ne $script:isElevated)
|
||||
$settings.SwapDacl = -not $script:isElevated
|
||||
|
||||
$harness = Join-Path $Scenario.Root 'Harness'
|
||||
New-Item -ItemType Directory -Path $harness -Force | Out-Null
|
||||
$tracePath = Join-Path $harness 'trace.txt'
|
||||
if (Test-Path -LiteralPath $tracePath) { Remove-Item -LiteralPath $tracePath }
|
||||
[System.IO.File]::WriteAllText((Join-Path $harness 'options.json'), ($settings | ConvertTo-Json))
|
||||
[System.IO.File]::WriteAllText((Join-Path $harness 'run.ps1'), $script:wrapperText)
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $script:powershell
|
||||
$quoted = @($Arguments | ForEach-Object { if ($_ -match '\s') { '"' + $_ + '"' } else { $_ } })
|
||||
$psi.Arguments = ('-NoProfile -ExecutionPolicy Bypass -File "{0}" {1}' -f (Join-Path $harness 'run.ps1'), ($quoted -join ' ')).Trim()
|
||||
$psi.WorkingDirectory = $(if ($WorkingDirectory) { $WorkingDirectory } else { $Scenario.Root })
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$psi.EnvironmentVariables['ProgramData'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['ALLUSERSPROFILE'] = $Scenario.ProgramData
|
||||
$psi.EnvironmentVariables['TEMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['TMP'] = $Scenario.Temp
|
||||
$psi.EnvironmentVariables['ProgramW6432'] = $Scenario.ProgramFiles
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
if (-not $process.WaitForExit(300000)) { $process.Kill(); throw 'The harness run did not end within five minutes.' }
|
||||
$process.WaitForExit()
|
||||
|
||||
return [pscustomobject]@{
|
||||
ExitCode = $process.ExitCode
|
||||
StdOut = $stdout.Result
|
||||
StdErr = $stderr.Result
|
||||
Trace = @(if (Test-Path -LiteralPath $tracePath) { Get-Content -LiteralPath $tracePath })
|
||||
}
|
||||
}
|
||||
|
||||
# Every entry below $Root, links as links and never followed; run-folder ids as <id>.
|
||||
# -Content adds a hash per file, for "exactly as it was".
|
||||
function Get-Tree {
|
||||
param([string]$Root, [switch]$Content)
|
||||
$entries = New-Object System.Collections.Generic.List[string]
|
||||
$walk = {
|
||||
param([string]$Folder)
|
||||
foreach ($entry in [System.IO.Directory]::GetFileSystemEntries($Folder)) {
|
||||
$relative = $entry.Substring($Root.Length + 1) -replace '-[0-9a-f]{8}(?=\\|$)', '-<id>'
|
||||
$attributes = [System.IO.File]::GetAttributes($entry)
|
||||
if (($attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { $entries.Add("$relative -> link"); continue }
|
||||
if (($attributes -band [System.IO.FileAttributes]::Directory) -ne 0) { $entries.Add("$relative\"); & $walk $entry; continue }
|
||||
if ($Content) { $relative += ' ' + (Get-FileHash -LiteralPath $entry -Algorithm SHA256).Hash.Substring(0, 16) }
|
||||
$entries.Add($relative)
|
||||
}
|
||||
}
|
||||
if ([System.IO.Directory]::Exists($Root)) { & $walk $Root }
|
||||
return (Join-Tree $entries)
|
||||
}
|
||||
|
||||
function Join-Tree {
|
||||
param([string[]]$Entries)
|
||||
$sorted = [string[]]@($Entries | Where-Object { $_ })
|
||||
[System.Array]::Sort($sorted, [System.StringComparer]::Ordinal)
|
||||
return ($sorted -join "`n")
|
||||
}
|
||||
|
||||
# The promise every run keeps, whatever the scenario asserts about ProgramData: nothing of
|
||||
# ours anywhere else. The scenario's own fixture folders are the only other entries.
|
||||
function Assert-NothingOutsideProgramData {
|
||||
param($Scenario)
|
||||
$fixtures = @('ProgramData', 'ProgramFiles', 'Temp', 'Server', 'Outside', 'Ime', 'Harness')
|
||||
$others = @([System.IO.Directory]::GetFileSystemEntries($Scenario.Root) | ForEach-Object { [System.IO.Path]::GetFileName($_) } | Where-Object { $_ -notin $fixtures })
|
||||
$others | Should -BeNullOrEmpty -Because 'the scripts write nowhere but ProgramData'
|
||||
Get-Tree $Scenario.ProgramFiles | Should -BeNullOrEmpty -Because 'nothing of ours stays under Program Files'
|
||||
Get-Tree $Scenario.Temp | Should -BeNullOrEmpty -Because 'the scripts never stage in the shared temp folder'
|
||||
}
|
||||
|
||||
# Every Describe runs this after each test: the last run of the test kept the promise.
|
||||
function Assert-PromiseOfLastRun {
|
||||
if (-not $script:promiseScenario) { return }
|
||||
$scenario = $script:promiseScenario
|
||||
$script:promiseScenario = $null
|
||||
Assert-NothingOutsideProgramData $scenario
|
||||
}
|
||||
|
||||
function Get-StdErrLines {
|
||||
param($Run)
|
||||
return @($Run.StdErr -split "`r?`n" | Where-Object { $_ })
|
||||
}
|
||||
|
||||
function New-Junction {
|
||||
param([string]$Link, [string]$Target)
|
||||
cmd.exe /c mklink /J "$Link" "$Target" | Out-Null
|
||||
}
|
||||
|
||||
function Add-File {
|
||||
param([string]$Path, [string]$Value = 'x')
|
||||
New-Item -ItemType Directory -Path (Split-Path $Path) -Force | Out-Null
|
||||
Set-Content -LiteralPath $Path -Value $Value
|
||||
}
|
||||
|
||||
# A base with what earlier bootstrap versions and agents could leave behind, plus a file
|
||||
# no version knows yet and a junction out of it.
|
||||
function Add-Leftovers {
|
||||
param($Scenario)
|
||||
Add-File (Join-Path $Scenario.Base 'Install-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-loader.log')
|
||||
Add-File (Join-Path $Scenario.Base 'Logs\bootstrap-script.log')
|
||||
Add-File (Join-Path $Scenario.Base 'Agent-Install\Agent\AutopilotMonitor.Agent.Core.dll')
|
||||
Add-File (Join-Path $Scenario.Base 'future-version-file.bin')
|
||||
Add-File (Join-Path $Scenario.Outside 'target\sentinel.txt') 'must survive'
|
||||
New-Junction -Link (Join-Path $Scenario.Base 'linked') -Target (Join-Path $Scenario.Outside 'target')
|
||||
}
|
||||
|
||||
$script:installedTree = @(
|
||||
'AutopilotMonitor\'
|
||||
'AutopilotMonitor\Agent\'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.Core.dll'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.cat'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe.config'
|
||||
'AutopilotMonitor\Logs\'
|
||||
)
|
||||
}
|
||||
|
||||
Describe 'loader 1.4' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs and leaves only the agent folder, with its log carried over' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Arguments @('-MaxBootstrapWindowHours', '24')
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
$run.Trace | Should -Contain 'Get-BootstrapDecision|MaxBootstrapWindowHours=24'
|
||||
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1'
|
||||
$run.Trace | Should -Contain 'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent.zip'
|
||||
$run.Trace | Should -Contain 'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version.json'
|
||||
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*\AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe --install exists=True' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
if ($script:isElevated) {
|
||||
(Get-Item -LiteralPath $s.Base).GetAccessControl('Owner').GetOwner([System.Security.Principal.SecurityIdentifier]).Value | Should -Be 'S-1-5-32-544'
|
||||
}
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind, also what older versions left and files no version knows' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves the folder of an agent exactly as it is, self-update leftovers included' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.Core.dll.old') 'previous version'
|
||||
Add-File (Join-Path $s.Base 'Updates\AutopilotMonitor-Agent-Update.zip') 'update'
|
||||
Add-File (Join-Path $s.Base 'Agent-Update\AutopilotMonitor.Agent.Core.dll') 'staged'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
Add-File (Join-Path $s.Base 'State\session.id') 'session'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\)'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves the folder alone when only the deployment marker <Case>' -TestCases @(
|
||||
@{ Case = 'is there (logs kept after enrollment)'; Options = @{ Marker = '2026-10-01T08:00:00Z' } }
|
||||
@{ Case = 'is missing but the agent task exists'; Options = @{ TaskPresent = $true } }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
}
|
||||
|
||||
It 'keeps its log when the installer fails; a later run removes it, a run within the hour leaves it' {
|
||||
$s = New-Scenario
|
||||
Remove-Item -LiteralPath (Join-Path $s.Server 'version.json')
|
||||
$failed = Invoke-Scenario $s -Script $script:loaderPath
|
||||
|
||||
$failed.ExitCode | Should -Be 0
|
||||
@(Get-StdErrLines $failed).Count | Should -Be 1
|
||||
$failed.StdErr | Should -Match '^AutopilotMonitor bootstrap failed: No SHA-256 hash available'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor-Loader-<id>\'
|
||||
'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'
|
||||
'AutopilotMonitor\'
|
||||
'AutopilotMonitor\Logs\'
|
||||
'AutopilotMonitor\Logs\bootstrap-loader.log'
|
||||
'AutopilotMonitor\Logs\bootstrap-script.log'
|
||||
))
|
||||
|
||||
Set-Content -LiteralPath (Join-Path $s.Server 'version.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
$kept = (Get-ChildItem -LiteralPath $s.ProgramData -Directory -Filter 'AutopilotMonitor-Loader-*').FullName
|
||||
|
||||
# Minutes later the folder may still belong to a running loader: it stays.
|
||||
$soon = Invoke-Scenario $s -Script $script:loaderPath
|
||||
$soon.ExitCode | Should -Be 0
|
||||
$soon.StdErr | Should -BeNullOrEmpty
|
||||
Test-Path -LiteralPath $kept | Should -BeTrue
|
||||
Get-Tree $s.Base | Should -Be (Join-Tree ((@($script:installedTree) | ForEach-Object { $_ -replace '^AutopilotMonitor\\', '' } | Where-Object { $_ }) + 'Logs\bootstrap-loader.log' + 'Logs\bootstrap-script.log'))
|
||||
|
||||
[System.IO.Directory]::SetCreationTimeUtc($kept, [System.DateTime]::UtcNow.AddHours(-2))
|
||||
$later = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Install = $false }
|
||||
$later.ExitCode | Should -Be 0
|
||||
Test-Path -LiteralPath $kept | Should -BeFalse
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Edit = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Edit = $null }
|
||||
@{ Case = 'an installer below 2.8'; Options = @{}; Edit = @('$ScriptVersion = "2.9"', '$ScriptVersion = "2.7"') }
|
||||
@{ Case = 'not the installer'; Options = @{}; Edit = @('$ScriptRole = "AutopilotMonitor-Installer"', '$ScriptRole = "AutopilotMonitor-Other"') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
if ($Edit) {
|
||||
$served = Join-Path $s.Server 'Install-AutopilotMonitor.ps1'
|
||||
$text = [System.IO.File]::ReadAllText($served)
|
||||
$text.Contains($Edit[0]) | Should -BeTrue
|
||||
[System.IO.File]::WriteAllText($served, $text.Replace($Edit[0], $Edit[1]))
|
||||
}
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Install-AutopilotMonitor.ps1' }).Count | Should -Be 3
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor-Loader-<id>\', 'AutopilotMonitor-Loader-<id>\bootstrap-loader.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loaderPath -Options @{ Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor loader: not running as SYSTEM or administrator; nothing was downloaded or changed.'
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'installer assigned directly' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeEach {
|
||||
$s = New-Scenario
|
||||
$imeCopy = Join-Path $s.Ime '0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0.ps1'
|
||||
Copy-Item -LiteralPath $script:installerPath -Destination $imeCopy
|
||||
}
|
||||
|
||||
It 'installs, and the copy IME runs stays where IME put it' {
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
Test-Path -LiteralPath $imeCopy | Should -BeTrue
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
}
|
||||
|
||||
# An agent that ran owns its folder, even after a failed --install; one that never started
|
||||
# leaves only payload, which a later run would take for an agent.
|
||||
It 'fails with exit code 1 and exactly one line on stderr: <Case>' -TestCases @(
|
||||
@{ Case = 'the download fails'; Missing = 'AutopilotMonitor-Agent.zip'; Options = @{}; Message = 'AutopilotMonitor bootstrap failed: The remote server returned an error: (404) Not Found.*'; AgentFolder = 'gone' }
|
||||
@{ Case = 'the payload fails its catalog'; Missing = $null; Options = @{ CatalogValid = $false }; Message = 'AutopilotMonitor bootstrap failed: Payload verification FAILED*'; AgentFolder = 'gone' }
|
||||
@{ Case = 'the agent install fails'; Missing = $null; Options = @{ InstallExitCode = 7 }; Message = 'AutopilotMonitor bootstrap failed: Agent install failed with exit code 7'; AgentFolder = 'stays' }
|
||||
@{ Case = 'the agent does not start'; Missing = $null; Options = @{ AgentStartFails = $true }; Message = "AutopilotMonitor bootstrap failed: Program 'AutopilotMonitor.Agent.exe' failed to run*"; AgentFolder = 'gone' }
|
||||
) {
|
||||
if ($Missing) { Remove-Item -LiteralPath (Join-Path $s.Server $Missing) }
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
@(Get-StdErrLines $run)[0] | Should -BeLike $Message
|
||||
Get-Tree $s.ProgramData | Should -Not -Match 'Agent-Install'
|
||||
Test-Path -LiteralPath (Join-Path $s.Base 'Agent') | Should -Be ($AgentFolder -eq 'stays')
|
||||
Test-Path -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-script.log') | Should -BeTrue
|
||||
}
|
||||
|
||||
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
|
||||
Set-EscapingPackage $s
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
@(Get-StdErrLines $run)[0] | Should -BeLike 'AutopilotMonitor bootstrap failed: The agent package has an entry that would be written outside the payload folder*'
|
||||
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and changes nothing' {
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run) | Should -Be 'AutopilotMonitor bootstrap: not running as SYSTEM or administrator; nothing was changed.'
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
}
|
||||
|
||||
It 'does not install into a folder that holds another account''s entries, and leaves them' {
|
||||
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$run = Invoke-Scenario $s -Script $imeCopy
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
@(Get-StdErrLines $run).Count | Should -Be 1
|
||||
$run.StdErr | Should -Match 'holds entries another account created'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'leaves another account''s entries and their folder when it skips' {
|
||||
Add-File (Join-Path $s.Base 'foreign-planted.dll') 'planted'
|
||||
Add-File (Join-Path $s.Base 'Logs\bootstrap-script.log') 'earlier run'
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\foreign-planted.dll'))
|
||||
}
|
||||
|
||||
It 'never follows an agent folder that is a link: <Case>' -TestCases @(
|
||||
@{ Case = 'install'; Install = $true; ExitCode = 1 }
|
||||
@{ Case = 'skip'; Install = $false; ExitCode = 0 }
|
||||
) {
|
||||
Add-File (Join-Path $s.Outside 'target\sentinel.txt') 'must survive'
|
||||
New-Junction -Link $s.Base -Target (Join-Path $s.Outside 'target')
|
||||
$run = Invoke-Scenario $s -Script $imeCopy -Options @{ Install = $Install }
|
||||
|
||||
$run.ExitCode | Should -Be $ExitCode
|
||||
Get-Tree $s.ProgramData | Should -Be 'AutopilotMonitor -> link'
|
||||
Get-Tree (Join-Path $s.Outside 'target') | Should -Be 'sentinel.txt'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'loader 1.2, frozen' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs with the current installer, which removes its copy and keeps the loader start line' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
$log = @(Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-loader.log'))
|
||||
$log[0] | Should -Match '^\[\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\] === Autopilot Monitor bootstrap loader v1\.2 started \(carried over by the bootstrapper\) ===$'
|
||||
}
|
||||
|
||||
It 'leaves the folder of an agent as it is, but not the copy the old loader saved there' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log', 'AutopilotMonitor\Logs\bootstrap-loader.log'
|
||||
))
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') | Should -Be 'running agent'
|
||||
}
|
||||
|
||||
It 'skips with the current installer and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:loader12Path -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'bootstrap MSI layouts, started in the folder the MSI installed' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It '<Name> skips and leaves <Left>' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log'); Left = 'nothing'; Tree = @() }
|
||||
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @(); Left = 'only the log the runner holds open'; Tree = @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\bootstrap-msi.log') }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $Tree)
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
|
||||
# MSI 1.3 installed its loader under Program Files, where nothing of ours belongs; the
|
||||
# installer takes it away in every outcome, also when an agent is present.
|
||||
It 'MSI 1.3 leaves nothing under Program Files when an agent is present' {
|
||||
$s = New-Scenario
|
||||
$msi = Join-Path $s.ProgramFiles 'AutopilotMonitor\Bootstrap'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'kept log'
|
||||
$before = Get-Tree $s.ProgramData -Content
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi 'Start-AutopilotMonitor.ps1') -Arguments @('-LogFileName', 'bootstrap-msi.log') -WorkingDirectory $msi -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData -Content | Should -Be $before
|
||||
Test-Path -LiteralPath (Join-Path $s.ProgramFiles 'AutopilotMonitor') | Should -BeFalse
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .* is left as it is\.'
|
||||
$run.StdOut | Should -Match 'Bootstrap removed, and with it the empty'
|
||||
}
|
||||
|
||||
It '<Name> installs' -TestCases @(
|
||||
@{ Name = 'MSI 1.3 (loader in Program Files)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Location = 'program-files'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.2 (loader 1.3)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.1 (loader 1.2)'; File = 'Start-AutopilotMonitor.ps1'; Source = 'loader12'; Arguments = @('-LogFileName', 'bootstrap-msi.log') }
|
||||
@{ Name = 'MSI 1.0 (own runner)'; File = 'Invoke-BootstrapDownload.ps1'; Source = 'msiRunner'; Arguments = @() }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
$msi = if ($Location -eq 'program-files') { Join-Path $s.Root 'ProgramFiles\AutopilotMonitor\Bootstrap' } else { Join-Path $s.Base 'Msi' }
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
$sourcePath = @{ loader = $script:loaderPath; loader12 = $script:loader12Path; msiRunner = $script:msiRunnerPath }[$Source]
|
||||
Copy-Item -LiteralPath $sourcePath -Destination (Join-Path $msi $File)
|
||||
$run = Invoke-Scenario $s -Script (Join-Path $msi $File) -Arguments $Arguments -WorkingDirectory $msi
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
if ($Location -eq 'program-files') { $run.StdOut | Should -Match 'Bootstrap removed, and with it the empty' }
|
||||
}
|
||||
}
|
||||
|
||||
# MSI 1.4 installs the loader copy under <base>\Msi and runs this command as SYSTEM from
|
||||
# System32: read the copy once, check our signature on those bytes, run those bytes. The
|
||||
# command is taken from Package.wxs and formatted as Windows Installer formats it (measured
|
||||
# on the built MSI, 2026-10-23); the wrapper runs it where the stubs apply.
|
||||
Describe 'bootstrap MSI 1.4: the action checks the loader copy and runs what it read' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$wxs = [xml][System.IO.File]::ReadAllText((Join-Path $script:repoRoot 'src\Agent\AutopilotMonitor.BootstrapMsi\Package.wxs'))
|
||||
$action = @($wxs.Wix.Package.CustomAction) | Where-Object { $_.Id -eq 'RunBootstrap' }
|
||||
if ($action.ExeCommand -notmatch ' -Command "(.*)"$') { throw 'Package.wxs: the action has no -Command argument.' }
|
||||
$script:actionCommand = $Matches[1]
|
||||
# What the build bakes in: the hash of the loader the package embeds, here the one served.
|
||||
$script:loaderSha256 = (Get-FileHash -LiteralPath $script:loaderPath -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
|
||||
function Format-MsiAction {
|
||||
param([string]$Text, [string]$InstallFolder)
|
||||
return $Text.Replace('$(var.LoaderSha256)', $script:loaderSha256).Replace('[\[]', '[').Replace('[\]]', ']').Replace('[\{]', '{').Replace('[\}]', '}').Replace('[INSTALLFOLDER]', $InstallFolder)
|
||||
}
|
||||
|
||||
function Add-MsiCopy {
|
||||
param($Scenario)
|
||||
$msi = Join-Path $Scenario.Base 'Msi'
|
||||
New-Item -ItemType Directory -Path $msi -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $msi 'Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
function Invoke-MsiAction {
|
||||
param($Scenario, [hashtable]$Options = @{})
|
||||
$command = Format-MsiAction -Text $script:actionCommand -InstallFolder ((Join-Path $Scenario.Base 'Msi') + '\')
|
||||
return Invoke-Scenario $Scenario -WorkingDirectory "$env:SystemRoot\System32" -Options ($Options + @{ CommandText = $command })
|
||||
}
|
||||
|
||||
$script:copyOnlyTree = @('AutopilotMonitor\', 'AutopilotMonitor\Msi\', 'AutopilotMonitor\Msi\Start-AutopilotMonitor.ps1')
|
||||
}
|
||||
|
||||
It 'is PowerShell that parses, with every bracket and brace escaped for Windows Installer' {
|
||||
# Without the [\x] escapes, the only bracket left is the one property reference.
|
||||
($script:actionCommand -replace '\[\\.\]', '').Replace('[INSTALLFOLDER]', '') | Should -Not -Match '[{}\[\]]'
|
||||
@([regex]::Matches($script:actionCommand, '\[[A-Za-z0-9_.]+\]') | ForEach-Object { $_.Value } | Sort-Object -Unique) | Should -Be @('[INSTALLFOLDER]')
|
||||
$errors = $null
|
||||
[System.Management.Automation.Language.Parser]::ParseInput((Format-MsiAction -Text $script:actionCommand -InstallFolder 'C:\ProgramData\AutopilotMonitor\Msi\'), [ref]$null, [ref]$errors) | Out-Null
|
||||
@($errors) | Should -BeNullOrEmpty
|
||||
}
|
||||
|
||||
It 'installs, and the copy goes with the emptied agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-msi.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
Get-Content -LiteralPath (Join-Path $s.Base 'Logs\bootstrap-msi.log') -Raw | Should -Match '=== Autopilot Monitor bootstrap loader v1\.4 started'
|
||||
}
|
||||
|
||||
It 'skips and leaves nothing behind' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Install = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'No agent on this device: .* removed\.'
|
||||
}
|
||||
|
||||
It 'leaves nothing behind when only the deployment marker is there' {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s -Options @{ Marker = '2026-10-01T08:00:00Z' }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Agent present \(deployment marker\): .*\\Msi removed, and with it the empty'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*Agent.zip' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'removes the copy and leaves the folder of an agent as it is' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'Logs\agent.log') 'agent log'
|
||||
Add-MsiCopy $s
|
||||
$run = Invoke-MsiAction $s
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\agent.log'))
|
||||
$run.StdOut | Should -Match 'Agent present \(agent executable\): .*\\Msi removed\.'
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 0
|
||||
}
|
||||
|
||||
It 'runs nothing it cannot verify: <Case>' -TestCases @(
|
||||
@{ Case = 'unsigned'; Options = @{ SignatureStatus = 'NotSigned' }; Append = $null }
|
||||
@{ Case = 'changed after signing'; Options = @{ SignatureStatus = 'HashMismatch' }; Append = $null }
|
||||
@{ Case = 'another publisher'; Options = @{ SignerSubject = 'CN=Somebody Else, O=Somebody Else Ltd, C=US' }; Append = $null }
|
||||
@{ Case = 'not the loader the package embeds, however validly signed'; Options = @{}; Append = "`r`n# one more line" }
|
||||
) {
|
||||
$s = New-Scenario
|
||||
Add-MsiCopy $s
|
||||
if ($Append) { [System.IO.File]::AppendAllText((Join-Path $s.Base 'Msi\Start-AutopilotMonitor.ps1'), $Append) }
|
||||
$run = Invoke-MsiAction $s -Options $Options
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree $script:copyOnlyTree)
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-AuthenticodeSignature|content:*' }).Count | Should -Be 1
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Get-BootstrapDecision|*' }).Count | Should -Be 0
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'OOBE script, as the backend generates it' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
It 'installs the agent with the bootstrap token and leaves only the agent folder' {
|
||||
$s = New-Scenario
|
||||
Add-Leftovers $s
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
|
||||
Get-Tree $s.Temp | Should -BeNullOrEmpty
|
||||
Get-Content -LiteralPath (Join-Path $s.Outside 'target\sentinel.txt') | Should -Be 'must survive'
|
||||
$run.Trace | Should -Contain ('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install --no-auth --bootstrap-token 22222222-2222-2222-2222-222222222222 --tenant-id 11111111-1111-1111-1111-111111111111 exists=True')
|
||||
@($run.Trace | Where-Object { $_ -like 'Test-FileCatalog|*' }).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'only runs --install again when the agent is there' {
|
||||
$s = New-Scenario
|
||||
Add-File (Join-Path $s.Base 'Agent\AutopilotMonitor.Agent.exe') 'running agent'
|
||||
Add-File (Join-Path $s.Base 'State\session.id') 'session'
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @(
|
||||
'AutopilotMonitor\', 'AutopilotMonitor\Agent\', 'AutopilotMonitor\Agent\AutopilotMonitor.Agent.exe'
|
||||
'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log', 'AutopilotMonitor\State\', 'AutopilotMonitor\State\session.id'
|
||||
))
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-WebRequest|*' }).Count | Should -Be 0
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-AgentInstallMode|*' }).Count | Should -Be 1
|
||||
}
|
||||
|
||||
It 'refuses an agent package with an entry outside the payload folder and writes nothing outside' {
|
||||
$s = New-Scenario
|
||||
Set-EscapingPackage $s
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
$run.StdOut | Should -Match 'The agent package has an entry that would be written outside the payload folder'
|
||||
Get-Tree $s.Root | Should -Not -Match 'outside\.txt'
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree @('AutopilotMonitor\', 'AutopilotMonitor\Logs\', 'AutopilotMonitor\Logs\oobe_bootstrap.log'))
|
||||
}
|
||||
|
||||
It 'refuses to run without SYSTEM or administrator rights and creates nothing' {
|
||||
$s = New-Scenario
|
||||
$run = Invoke-Scenario $s -Script $script:oobeScriptPath -Options @{ Iex = $true; Elevated = $false }
|
||||
|
||||
$run.ExitCode | Should -Be 1
|
||||
$run.StdOut | Should -Match 'Run this script from PowerShell as SYSTEM or administrator\. Nothing was changed\.'
|
||||
Get-Tree $s.ProgramData | Should -BeNullOrEmpty
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'publish pipeline and dev channel' {
|
||||
AfterEach { Assert-PromiseOfLastRun }
|
||||
BeforeAll {
|
||||
$script:rendered = Join-Path $TestDrive 'rendered'
|
||||
$script:pwsh = (Get-Command pwsh -CommandType Application -ErrorAction Stop | Select-Object -First 1).Source
|
||||
$script:publish = Join-Path $script:repoRoot '.github\scripts\Publish-BootstrapScripts.ps1'
|
||||
|
||||
# Output read as text from a process of its own: a stderr line must not become an error
|
||||
# record, which ends the test under the 'Stop' preference CI runs with.
|
||||
function Invoke-Publish {
|
||||
param([string[]]$Arguments)
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = $script:pwsh
|
||||
$psi.Arguments = ('-NoProfile -File "{0}" {1}' -f $script:publish, ($Arguments -join ' ')).Trim()
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.RedirectStandardOutput = $true
|
||||
$psi.RedirectStandardError = $true
|
||||
$process = [System.Diagnostics.Process]::Start($psi)
|
||||
$stdout = $process.StandardOutput.ReadToEndAsync()
|
||||
$stderr = $process.StandardError.ReadToEndAsync()
|
||||
$process.WaitForExit()
|
||||
return [pscustomobject]@{ ExitCode = $process.ExitCode; Output = @(($stdout.Result + "`n" + $stderr.Result) -split "`r?`n") }
|
||||
}
|
||||
|
||||
$dryRun = Invoke-Publish @('-DryRun', '-SignedOutputDir', ('"' + $script:rendered + '"'))
|
||||
if ($dryRun.ExitCode -ne 0) { throw "Publish dry run failed: $($dryRun.Output -join [Environment]::NewLine)" }
|
||||
$script:publishOutput = $dryRun.Output
|
||||
}
|
||||
|
||||
# A loader that went live first would refuse the installer still being served.
|
||||
It 'puts both installers before both loaders' {
|
||||
$order = @($script:publishOutput | Where-Object { $_ -match '\[dry-run\] (\S+\.ps1) ' } | ForEach-Object { $Matches[1] })
|
||||
$order[0..3] | Should -Be @('Install-AutopilotMonitor.ps1', 'Install-AutopilotMonitor-Dev.ps1', 'Start-AutopilotMonitor.ps1', 'Start-AutopilotMonitor-Dev.ps1')
|
||||
@($script:publishOutput | Where-Object { $_ -like '*Installer accepted: AutopilotMonitor-Installer v*' }).Count | Should -Be 2
|
||||
}
|
||||
|
||||
It 'refuses to publish an installer the loader would refuse: <Case>' -TestCases @(
|
||||
@{ Case = 'below the minimum version'; Old = '$ScriptVersion = "2.9"'; New = '$ScriptVersion = "2.7"' }
|
||||
@{ Case = 'without its role line'; Old = '$ScriptRole = "AutopilotMonitor-Installer"'; New = '# no role' }
|
||||
@{ Case = 'with a second version line'; Old = '$ScriptVersion = "2.9"'; New = "`$ScriptVersion = `"2.9`"`n`$ScriptVersion = `"3.0`"" }
|
||||
) {
|
||||
$root = Join-Path $TestDrive ('repo' + [guid]::NewGuid().ToString('N').Substring(0, 6))
|
||||
New-Item -ItemType Directory -Path (Join-Path $root 'scripts\Bootstrap') -Force | Out-Null
|
||||
Copy-Item -LiteralPath $script:loaderPath -Destination (Join-Path $root 'scripts\Bootstrap')
|
||||
$text = [System.IO.File]::ReadAllText($script:installerPath)
|
||||
$text.Contains($Old) | Should -BeTrue
|
||||
[System.IO.File]::WriteAllText((Join-Path $root 'scripts\Bootstrap\Install-AutopilotMonitor.ps1'), $text.Replace($Old, $New))
|
||||
|
||||
$refused = Invoke-Publish @('-DryRun', '-Channel', 'dev', '-RepoRoot', ('"' + $root + '"'))
|
||||
|
||||
$refused.ExitCode | Should -Not -Be 0
|
||||
($refused.Output -join "`n") | Should -Match 'The loader would refuse Install-AutopilotMonitor-Dev\.ps1'
|
||||
($refused.Output -join "`n") | Should -Not -Match '\[dry-run\]'
|
||||
}
|
||||
|
||||
It 'the dev loader fetches the dev installer, which installs the dev agent' {
|
||||
$s = New-Scenario
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor-Dev.ps1') -Destination $s.Server
|
||||
Copy-Item -LiteralPath $script:agentZip -Destination (Join-Path $s.Server 'AutopilotMonitor-Agent-dev.zip')
|
||||
Set-Content -LiteralPath (Join-Path $s.Server 'version-dev.json') -Value ('{"version":"2.0.9999","sha256":"' + $script:agentZipSha256 + '"}')
|
||||
Remove-Item -LiteralPath (Join-Path $s.Server 'Install-AutopilotMonitor.ps1'), (Join-Path $s.Server 'AutopilotMonitor-Agent.zip'), (Join-Path $s.Server 'version.json')
|
||||
$devLoader = Join-Path $s.Ime 'Start-AutopilotMonitor-Dev.ps1'
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor-Dev.ps1') -Destination $devLoader
|
||||
$run = Invoke-Scenario $s -Script $devLoader
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
@($run.Trace | Where-Object { $_ -like 'Invoke-*' }) | Should -Be @(
|
||||
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor-Dev.ps1'
|
||||
'Invoke-RestMethod|https://download.autopilotmonitor.com/agent/version-dev.json'
|
||||
'Invoke-WebRequest|https://download.autopilotmonitor.com/agent/AutopilotMonitor-Agent-dev.zip'
|
||||
('Invoke-AgentInstallMode|' + $s.Base + '\Agent\AutopilotMonitor.Agent.exe --install exists=True')
|
||||
)
|
||||
Get-Tree $s.ProgramData | Should -Be (Join-Tree ($script:installedTree + 'AutopilotMonitor\Logs\bootstrap-loader.log' + 'AutopilotMonitor\Logs\bootstrap-script.log'))
|
||||
}
|
||||
|
||||
It 'the stable loader in its published form runs the stable installer in its published form' {
|
||||
$s = New-Scenario
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Install-AutopilotMonitor.ps1') -Destination $s.Server -Force
|
||||
$loader = Join-Path $s.Ime 'Start-AutopilotMonitor.ps1'
|
||||
Copy-Item -LiteralPath (Join-Path $script:rendered 'Start-AutopilotMonitor.ps1') -Destination $loader
|
||||
[System.IO.File]::ReadAllText($loader).Contains("`r`n") | Should -BeTrue
|
||||
$run = Invoke-Scenario $s -Script $loader
|
||||
|
||||
$run.ExitCode | Should -Be 0
|
||||
$run.StdErr | Should -BeNullOrEmpty
|
||||
$run.StdOut | Should -Match 'Installer accepted: AutopilotMonitor-Installer v2\.9'
|
||||
$run.StdOut | Should -Match 'Bootstrap Completed Successfully'
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user