Files
AutopilotMonitor/.github/workflows/publish-scripts.yml
T
Oliver Kieselbach 0ac548bfce feat(bootstrap): two-stage loader and Authenticode-signed customer scripts (D-224)
Start-AutopilotMonitor.ps1 is the new stage 1 -- the file an administrator assigns in
Intune. It carries no enrollment logic: it downloads the published installer, verifies its
Authenticode signature, and runs it in the same process, passing through every remaining
argument. Assigning the installer directly stays supported and unchanged.

The point of the split is that the customer-held artifact stops changing. Today every
bootstrap change forces customers to replace their Intune copy, so the fleet runs a
distribution of script versions we do not control. It is also what makes script signing
worth doing: signing a file that gets swapped by hand each release is a one-off gain,
signing a loader that never changes is permanent.

The loader is deliberately minimal, because it is the one piece that cannot be fixed
remotely -- every guard placed in it would be frozen for years. All guards stay in stage 2.

Verification pins the certificate SUBJECT, never a thumbprint: certificates get renewed and
a deployed loader cannot be, so a thumbprint pin would strand the fleet at the next
renewal. A hash oracle was rejected for a different reason -- it would have to be fetched
from the same host as the script, so it could not survive a compromise of that host, while
the expected publisher travels inside the loader.

Fail-closed on verification, fail-soft on exit: nothing runs without a valid signature from
us (running unverified code as SYSTEM is the risk being removed here), but the loader still
exits 0, because a device without monitoring is acceptable and a failed enrollment step is
not. The accepted cost is that a device which can never fetch or verify stays silent.

Publisher changes:

- every published script is signed (loader, installer, both -Dev renders,
  Test-ShouldBootstrapAgent, Grant-AutopilotMonitorAddOn), and signing is the LAST
  transformation: it covers exactly the CRLF/UTF-8 bytes Get-PublishBytes produced, so
  nothing may normalise or re-encode them afterwards.
- signing is mandatory for a real publish. Only -DryRun may run unsigned, because the PR
  gate executes the publisher without any Azure login.
- comparisons against a published copy strip the signature block first. A timestamped
  signature differs on every run, so without stripping the version-bump guard and the
  drift check would report a change for every unchanged script.
- the bump guard became Assert-VersionBump and now covers the loader as well.
- version.json gains loaderVersion. Additive, no consumer yet: stage 2 is current by
  construction, so only stage 1's version says anything about a customer's Intune copy.
- -SignedOutputDir writes the exact uploaded bytes for the CI artifact, in a dry run too --
  that is how a signed script gets inspected before it reaches anyone.

publish-scripts.yml logs in with the signing identity first and only to fetch the Key Vault
token (azure/login sets the default az account and the storage login has to be the one that
stays), then publishes and offers the signed scripts as an artifact. build-agent.yml passes
the same parameters on a stable cutover.

Tests: 14 new Pester cases, including that a validly signed script from a FOREIGN publisher
is refused -- that is what an attacker with any code-signing certificate can produce. Full
bootstrap suite on Windows PowerShell 5.1: 43/43.

Not covered: the MSI runner still downloads stage 2 without verifying it, and the customer
docs still describe the direct assignment only.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-08 18:48:21 +02:00

135 lines
5.7 KiB
YAML

name: Publish customer scripts
# Keeps the scripts behind download.autopilotmonitor.com/agent/ current WITHOUT waiting
# for an agent release. Before this workflow existed, the script blobs rotated only on a
# publish_as_stable run of build-agent.yml, so a bootstrap fix sat unpublished until the
# next agent cutover -- while the WDP bootstrap MSI downloads the published copy on every
# enrolling device.
#
# Same publish code as the release path (.github/scripts/Publish-BootstrapScripts.ps1);
# this workflow only supplies a second trigger for it.
on:
push:
branches: [main]
paths:
- 'scripts/Bootstrap/**'
- 'scripts/CustomerSetup/**'
- '.github/scripts/Publish-BootstrapScripts.ps1'
workflow_dispatch:
inputs:
dry_run:
description: 'Run every check and report drift, upload nothing'
required: true
type: boolean
default: false
permissions:
contents: read
id-token: write # OIDC federated login for the storage writes (secret-less)
# Shared with build-agent.yml: a script publish and an agent release both touch
# version.json, and the bootstrapVersion read-modify-write must not interleave with the
# release writing its agent fields. Never cancel -- a half-published set is worse than a queue.
concurrency:
group: agent-blob-publish
cancel-in-progress: false
# 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.
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:
gates:
uses: ./.github/workflows/bootstrap-script-gates.yml
publish:
name: Publish to the download alias
needs: gates
runs-on: windows-latest
steps:
- uses: actions/checkout@v7
- name: Check code-signing configuration
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. Published customer scripts must be signed."
}
}
# 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:`.
- name: Login to Azure (code-signing identity)
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
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
# Authorizes the eu blob writes and the AdminConfiguration merge. The gates job above
# runs the same publisher with -DryRun and deliberately without a login -- a dry run
# must never need Azure credentials.
- name: Login to Azure (OIDC)
uses: azure/login@7ddb5af1ef8758cf1353cf3b42f940aee27ba21c # v3
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
# Version pinned so a new or compromised NuGet release cannot flow into signing
# unreviewed; roll-forward because the tool targets an older runtime than the SDK
# preinstalled on the runner.
- name: Install AzureSignTool
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: dotnet tool install --no-cache --global AzureSignTool --version 7.0.1
- name: Publish bootstrap + customer scripts
shell: pwsh
env:
DOTNET_ROLL_FORWARD: LatestMajor
run: |
$dryRun = '${{ inputs.dry_run }}' -eq 'true'
./.github/scripts/Publish-BootstrapScripts.ps1 `
-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
# The exact bytes that were (or, on a dry run, would have been) uploaded. A dry-run
# dispatch is therefore the way to inspect a signed script locally before it reaches
# a single customer. Kept on failure too: a failed alias verification is precisely
# when someone wants to look at the file.
- name: Upload signed scripts
if: always()
uses: actions/upload-artifact@v7
with:
name: signed-scripts
path: ${{ runner.temp }}/signed-scripts
if-no-files-found: ignore