diff --git a/.github/scripts/Publish-BootstrapScripts.ps1 b/.github/scripts/Publish-BootstrapScripts.ps1 index 6c1b1a6f..a8c9b624 100644 --- a/.github/scripts/Publish-BootstrapScripts.ps1 +++ b/.github/scripts/Publish-BootstrapScripts.ps1 @@ -16,20 +16,30 @@ enrolling device, so "current" is a correctness property, not cosmetics. Steps, in order: - 1. Parse $ScriptVersion from the bootstrap source. - 2. Render the -Dev variant by literal substitution of the two URL/manifest + 1. Parse $ScriptVersion 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. - 3. Version-bump guard: if the published bootstrap differs from the source - but carries the same $ScriptVersion, abort. Otherwise the docs badge, + 3. Version-bump guard: if a published script differs from its source but + 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. Upload every script blob with Cache-Control: no-cache (they rotate in + 4. Build the publish set (loader, bootstrap, both dev renders, the extras). + 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 place), mirrored fail-soft to the legacy account. - 5. Reconcile the two version oracles: version.json.bootstrapVersion - (read-modify-write under If-Match, so a concurrent agent release cannot - lose its agent fields) and AdminConfiguration.LatestBootstrapV2ScriptVersion. - 6. Verify through the alias -- re-download each blob and compare SHA-256. + 7. Reconcile the version oracles: 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. + + Comparisons against a published script always strip its signature block first + (Get-ComparableText). A signature carries a timestamp, so signed bytes differ on + every run; without stripping, the bump guard and the drift check would report a + change for every unchanged script. .PARAMETER LegacySasToken Container SAS for the legacy account. Optional -- a mirror failure warns, never fails. @@ -40,18 +50,39 @@ Repository root. Defaults to the parent of .github/scripts. .PARAMETER DryRun - Run every check and render, upload nothing. + Run every check and render, upload nothing. A dry run signs when a token is supplied + and skips signing when it is not: the PR gate runs this without any Azure login. + +.PARAMETER SigningToken + Azure Key Vault access token for AzureSignTool. MANDATORY for a real publish -- an + unsigned customer script is not something this repo publishes. Together with + -KeyVaultUrl and -CertificateName. + +.PARAMETER ExpectedPublisher + Optional -like pattern the signer subject must match after signing. Pins the publisher + instead of accepting any trusted signature; the loader on the device pins the same way. + +.PARAMETER SignedOutputDir + 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. .EXAMPLE ./Publish-BootstrapScripts.ps1 -DryRun .EXAMPLE - ./Publish-BootstrapScripts.ps1 -LegacySasToken $env:LEGACY_SAS + ./Publish-BootstrapScripts.ps1 -LegacySasToken $env:LEGACY_SAS -SigningToken $env:AKV_TOKEN ` + -KeyVaultUrl $env:KV_URL -CertificateName $env:CERT #> [CmdletBinding()] param( [string]$LegacySasToken, [string]$RepoRoot, + [string]$SigningToken, + [string]$KeyVaultUrl, + [string]$CertificateName, + [string]$TimestampUrl = 'http://timestamp.acs.microsoft.com', + [string]$ExpectedPublisher, + [string]$SignedOutputDir, [switch]$DryRun ) @@ -69,8 +100,23 @@ $TableUrl = "https://autopilotmonitoreu.table.core.windows.net/AdminCo $BootstrapSource = Join-Path $RepoRoot 'scripts/Bootstrap/Install-AutopilotMonitor.ps1' $BootstrapBlob = 'Install-AutopilotMonitor.ps1' $DevBlob = 'Install-AutopilotMonitor-Dev.ps1' +# Stage 1. What a customer assigns in Intune once; it downloads the bootstrap above on +# every device and verifies its publisher before running it. +$LoaderSource = Join-Path $RepoRoot 'scripts/Bootstrap/Start-AutopilotMonitor.ps1' +$LoaderBlob = 'Start-AutopilotMonitor.ps1' +$LoaderDevBlob = 'Start-AutopilotMonitor-Dev.ps1' $ScriptContentType = 'text/plain; charset=utf-8' +$SigningEnabled = -not [string]::IsNullOrWhiteSpace($SigningToken) +if (-not $DryRun -and -not $SigningEnabled) { + throw ('Publishing requires -SigningToken (plus -KeyVaultUrl and -CertificateName): the ' + + 'loader on every device verifies the publisher of what it downloads, so an unsigned ' + + 'script would be rejected in the field. Only -DryRun may run without signing.') +} +if ($SigningEnabled -and ([string]::IsNullOrWhiteSpace($KeyVaultUrl) -or [string]::IsNullOrWhiteSpace($CertificateName))) { + throw '-SigningToken needs -KeyVaultUrl and -CertificateName.' +} + function Get-HttpStatus { param($ErrorRecord) if ($ErrorRecord.Exception.PSObject.Properties['Response'] -and $ErrorRecord.Exception.Response) { @@ -116,6 +162,122 @@ function Get-PublishBytes { return ,(New-Object System.Text.UTF8Encoding $false).GetBytes($normalised) } +# A PowerShell signature is appended as a comment block at the end of the file, so the +# published bytes are "source + signature". Everything that compares a published script to +# its source has to cut that block off first -- the signature carries a timestamp and is +# therefore different on every publish, while the script above it is unchanged. +function Remove-SignatureBlock { + param([string]$Text) + $marker = '# SIG # Begin signature block' + $index = $Text.IndexOf($marker) + if ($index -lt 0) { return $Text } + return $Text.Substring(0, $index) +} + +# Comparison form: no signature, LF, no trailing blank lines. Used for "is the published +# copy still this source" -- never for what gets uploaded. +function Get-ComparableText { + param([string]$Text) + return (Remove-SignatureBlock $Text).Replace("`r`n", "`n").TrimEnd("`n") +} + +# Signs the publish set in place: each item's bytes are written to a work directory, signed +# there, verified, and read back as the bytes that will be uploaded. Signing has to be the +# last transformation -- the signature covers exactly these bytes, so nothing may normalise +# or re-encode them afterwards. +function Set-PublishSetSignature { + param([object[]]$Items, [string]$WorkDir) + + if (-not (Get-Command azuresigntool -ErrorAction SilentlyContinue)) { + throw "azuresigntool is not on PATH. Install it with: dotnet tool install --global AzureSignTool --version 7.0.1" + } + + New-Item -ItemType Directory -Force -Path $WorkDir | Out-Null + + $paths = @() + foreach ($item in $Items) { + $path = Join-Path $WorkDir $item.BlobName + [System.IO.File]::WriteAllBytes($path, [byte[]]$item.Bytes) + $paths += $path + } + + Write-Host "Signing $($paths.Count) script(s) with certificate '$CertificateName'..." + & azuresigntool.exe sign --verbose ` + --azure-key-vault-url $KeyVaultUrl ` + --azure-key-vault-accesstoken $SigningToken ` + --azure-key-vault-certificate $CertificateName ` + --timestamp-rfc3161 $TimestampUrl ` + --file-digest sha256 ` + --description 'Autopilot Monitor bootstrap' ` + --description-url 'https://www.autopilotmonitor.com' ` + $paths + + # Native tool: PowerShell does not throw on a non-zero exit, and an unnoticed signing + # failure would publish unsigned scripts that every loader in the field then rejects. + if ($LASTEXITCODE -ne 0) { + throw "azuresigntool failed with exit code $LASTEXITCODE -- nothing was uploaded." + } + + foreach ($item in $Items) { + $path = Join-Path $WorkDir $item.BlobName + $sig = Get-AuthenticodeSignature -FilePath $path + if ($sig.Status -ne 'Valid') { + throw "Signature on $($item.BlobName) is '$($sig.Status)', expected 'Valid'." + } + # Without a timestamp the signature dies with the certificate, and these scripts sit + # on customer devices far longer than a certificate lifetime. + if (-not $sig.TimeStamperCertificate) { + throw "Signature on $($item.BlobName) carries no timestamp." + } + if ($ExpectedPublisher -and $sig.SignerCertificate.Subject -notlike $ExpectedPublisher) { + throw "$($item.BlobName) was signed by '$($sig.SignerCertificate.Subject)', expected '$ExpectedPublisher'." + } + $item.Bytes = ,([System.IO.File]::ReadAllBytes($path)) + Write-Host " signed $($item.BlobName) ($($item.Bytes.Length) bytes, signer $($sig.SignerCertificate.Subject.Split(',')[0]))" + } +} + +# Aborts when a published script changed behaviour without a version bump. Consumers act on +# the version (docs badge, version.json, the portal's outdated-script hint), so an unchanged +# version has to mean unchanged behaviour. +function Assert-VersionBump { + param( + [string]$BlobName, + [string]$SourceContent, + [string]$SourceOrigin, + [string]$SourceVersion + ) + + $publishedBytes = Get-PublishedBytes "$AliasUrl/$BlobName" + if ($null -eq $publishedBytes) { + Write-Host "No published $BlobName yet -- version-bump guard skipped (first publish)" + return + } + + $publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes) + if ((Get-ComparableText $publishedText) -eq (Get-ComparableText $SourceContent)) { + Write-Host "Published $BlobName is already this source (signature block aside)" + return + } + + $publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BlobName" -Optional + + if ($null -eq $publishedVersion) { + Write-Host "::warning::The published $BlobName is not a readable script (no ScriptVersion found). Republishing over it." + } elseif ($publishedVersion -ne $SourceVersion) { + Write-Host "Version bump: $publishedVersion -> $SourceVersion ($BlobName)" + } elseif ((Get-CodeFingerprint -Text (Remove-SignatureBlock $publishedText) -Origin "$AliasUrl/$BlobName") -eq + (Get-CodeFingerprint -Text $SourceContent -Origin $SourceOrigin)) { + # Comments, typos in comments, reflow -- nothing a consumer of the version can act on. + Write-Host "::warning::$BlobName changed in comments or formatting only; publishing under the unchanged version $SourceVersion." + } else { + throw ("Version-bump guard: $BlobName changed behaviour but ScriptVersion is still $SourceVersion. " + + "Bump it in $SourceOrigin -- the docs badge, version.json and the " + + 'portal outdated-script hint all read that value, and customers decide from it whether ' + + 'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.') + } +} + function Get-BootstrapScriptVersion { param([string]$Content, [string]$Origin, [switch]$Optional) if ($Content -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"') { return $Matches[1] } @@ -143,12 +305,16 @@ function Get-CodeFingerprint { return (($tokens | Where-Object { $ignored -notcontains $_.Kind.ToString() } | ForEach-Object { $_.Text }) -join "`n") } -# ------------------------------------------------------------------ 1. source +# ------------------------------------------------------------------ 1. sources $sourceContent = Get-Content $BootstrapSource -Raw $sourceBytes = Get-PublishBytes $sourceContent $scriptVersion = Get-BootstrapScriptVersion -Content $sourceContent -Origin $BootstrapSource Write-Host "Bootstrap script version: $scriptVersion" +$loaderContent = Get-Content $LoaderSource -Raw +$loaderVersion = Get-BootstrapScriptVersion -Content $loaderContent -Origin $LoaderSource +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"' @@ -161,33 +327,21 @@ if ($sourceContent.IndexOf($stableUrlLiteral) -lt 0 -or $sourceContent.IndexOf($ $devContent = $sourceContent.Replace($stableUrlLiteral, $devUrlLiteral).Replace($stableManLiteral, $devManLiteral) Write-Host "Rendered $DevBlob (dev agent URL + version-dev.json)" -# ------------------------------------------------------------------ 3. bump guard -$publishedBytes = Get-PublishedBytes "$AliasUrl/$BootstrapBlob" -if ($null -eq $publishedBytes) { - Write-Host 'No published bootstrap yet -- version-bump guard skipped (first publish)' -} elseif ((Get-Sha256 $publishedBytes) -eq (Get-Sha256 $sourceBytes)) { - Write-Host 'Published bootstrap is already byte-identical to the source' -} else { - $publishedText = [System.Text.Encoding]::UTF8.GetString($publishedBytes) - $publishedVersion = Get-BootstrapScriptVersion -Content $publishedText -Origin "$AliasUrl/$BootstrapBlob" -Optional +# 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"' - if ($null -eq $publishedVersion) { - Write-Host "::warning::The published $BootstrapBlob is not a readable bootstrap script (no ScriptVersion found). Republishing over it." - } elseif ($publishedVersion -ne $scriptVersion) { - Write-Host "Version bump: $publishedVersion -> $scriptVersion" - } elseif ((Get-CodeFingerprint -Text $publishedText -Origin "$AliasUrl/$BootstrapBlob") -eq - (Get-CodeFingerprint -Text $sourceContent -Origin $BootstrapSource)) { - # Comments, typos in comments, reflow -- nothing a consumer of the version can act on. - Write-Host "::warning::$BootstrapBlob changed in comments or formatting only; publishing under the unchanged version $scriptVersion." - } else { - throw ("Version-bump guard: $BootstrapBlob changed behaviour but ScriptVersion is still $scriptVersion. " + - "Bump it in $BootstrapSource -- the docs badge, version.json.bootstrapVersion and the " + - 'portal outdated-script hint all read that value, and customers decide from it whether ' + - 'to re-upload their Intune copy. Comment-only and formatting changes do not need a bump.') - } +if ($loaderContent.IndexOf($stableLoaderLiteral) -lt 0) { + throw "Loader dev-render: anchor literal missing in $LoaderSource" } +$devLoaderContent = $loaderContent.Replace($stableLoaderLiteral, $devLoaderLiteral) +Write-Host "Rendered $LoaderDevBlob (points at $DevBlob)" -# ------------------------------------------------------------------ 4. upload +# ------------------------------------------------------------------ 3. bump guards +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. $extraSources = @( @{ Path = 'scripts/Bootstrap/Test-ShouldBootstrapAgent.ps1'; BlobName = 'Test-ShouldBootstrapAgent.ps1' } @@ -197,15 +351,29 @@ $extraSources = @( # Uninstall-AutopilotMonitor.ps1 is deliberately NOT published: nothing links it, and # an unauthenticated uninstall script on the public download host is not a feature. +# 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 = $BootstrapBlob; Bytes = $sourceBytes }) -$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; 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) }) +$publishSet.Add([pscustomobject]@{ BlobName = $BootstrapBlob; Text = $sourceContent; Bytes = $sourceBytes }) +$publishSet.Add([pscustomobject]@{ BlobName = $DevBlob; Text = $devContent; Bytes = (Get-PublishBytes $devContent) }) foreach ($extra in $extraSources) { $extraPath = Join-Path $RepoRoot $extra.Path if (-not (Test-Path $extraPath)) { throw "Publish source missing: $extraPath" } - $publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Bytes = (Get-PublishBytes (Get-Content $extraPath -Raw)) }) + $extraText = Get-Content $extraPath -Raw + $publishSet.Add([pscustomobject]@{ BlobName = $extra.BlobName; Text = $extraText; Bytes = (Get-PublishBytes $extraText) }) +} + +# ------------------------------------------------------------------ 5. sign +# After this point the bytes are final. Normalising, re-encoding or rewriting a line ending +# would invalidate the signature, and the device-side loader would refuse the script. +if ($SigningEnabled) { + Set-PublishSetSignature -Items $publishSet -WorkDir (Join-Path ([System.IO.Path]::GetTempPath()) "apm-publish-$PID") +} else { + Write-Host '::warning::No signing token supplied -- dry run continues with unsigned scripts.' } $legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' } @@ -214,6 +382,7 @@ $legacySas = if ($LegacySasToken) { $LegacySasToken.TrimStart('?') } else { '' } # (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 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[]] @@ -221,11 +390,21 @@ foreach ($item in $publishSet) { [byte[]]$bytes = $item.Bytes $item | Add-Member -NotePropertyName Sha256 -NotePropertyValue (Get-Sha256 $bytes) -Force + # Exactly the bytes that go on the wire, for the CI artifact. Written in a dry run too: + # that is the run an operator uses to inspect a signed script before it is published. + if ($SignedOutputDir) { + New-Item -ItemType Directory -Force -Path $SignedOutputDir | Out-Null + [System.IO.File]::WriteAllBytes((Join-Path $SignedOutputDir $item.BlobName), $bytes) + } + if ($DryRun) { - # Doubles as a drift check: says per blob whether the alias already serves this source. + # Doubles as a drift check: says per blob whether the alias already serves this + # source. Compared as text without the signature block -- signed bytes differ on + # every run, so a byte comparison would report drift for every unchanged script. $served = Get-PublishedBytes "$AliasUrl/$($item.BlobName)" $state = if ($null -eq $served) { 'MISSING on the alias' } - elseif ((Get-Sha256 $served) -eq $item.Sha256) { 'already current' } + elseif ((Get-ComparableText ([System.Text.Encoding]::UTF8.GetString($served))) -eq + (Get-ComparableText $item.Text)) { 'already current' } else { 'STALE on the alias' } Write-Host " [dry-run] $($item.BlobName) ($($bytes.Length) bytes, sha256 $($item.Sha256)) -- $state" continue @@ -260,7 +439,7 @@ foreach ($item in $publishSet) { } } -# ------------------------------------------------------------------ 5. version oracles +# ------------------------------------------------------------------ 7. 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. @@ -272,11 +451,15 @@ if (-not $DryRun) { } else { [string]$manifestResp.Content } $manifest = $manifestRaw | ConvertFrom-Json - if ($manifest.bootstrapVersion -eq $scriptVersion) { - Write-Host "version.json already reports bootstrapVersion $scriptVersion" + if ($manifest.bootstrapVersion -eq $scriptVersion -and $manifest.loaderVersion -eq $loaderVersion) { + Write-Host "version.json already reports bootstrapVersion $scriptVersion and loaderVersion $loaderVersion" } else { $etag = '"' + ([string]($manifestResp.Headers['ETag'] | Select-Object -First 1)).Trim('"') + '"' $manifest | Add-Member -NotePropertyName 'bootstrapVersion' -NotePropertyValue $scriptVersion -Force + # Additive: no consumer reads loaderVersion yet. It exists so support can tell + # which stage-1 file a device was assigned -- stage 2 is always current by + # construction, so its version says nothing about the customer's Intune copy. + $manifest | Add-Member -NotePropertyName 'loaderVersion' -NotePropertyValue $loaderVersion -Force $body = [System.Text.Encoding]::UTF8.GetBytes(($manifest | ConvertTo-Json -Compress)) $manifestHeaders = @{ 'x-ms-blob-type' = 'BlockBlob' @@ -285,7 +468,7 @@ if (-not $DryRun) { 'If-Match' = $etag } Invoke-RestMethod -Uri "$ContainerUrl/version.json" -Method Put -Headers ($authHeaders + $manifestHeaders) -Body $body | Out-Null - Write-Host "version.json bootstrapVersion -> $scriptVersion (agent fields untouched)" + Write-Host "version.json bootstrapVersion -> $scriptVersion, loaderVersion -> $loaderVersion (agent fields untouched)" if ($legacySas) { try { @@ -317,7 +500,7 @@ if ($DryRun) { Write-Host "AdminConfiguration.LatestBootstrapV2ScriptVersion = $scriptVersion" } -# ------------------------------------------------------------------ 6. verify via alias +# ------------------------------------------------------------------ 8. verify via alias if (-not $DryRun) { $pending = [System.Collections.ArrayList]::new() $publishSet | ForEach-Object { [void]$pending.Add($_) } @@ -349,7 +532,8 @@ if ($env:GITHUB_STEP_SUMMARY) { $summary = @( "### Bootstrap scripts: $mode", '', - "Bootstrap script version: **$scriptVersion**", + "Loader script version: **$loaderVersion** | Bootstrap script version: **$scriptVersion**", + "Signed: **$(if ($SigningEnabled) { "yes, certificate '$CertificateName'" } else { 'NO (unsigned dry run)' })**", '' ) + ($publishSet | ForEach-Object { "- $($_.BlobName) -- sha256 $($_.Sha256)" }) ($summary -join [Environment]::NewLine) | Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8 diff --git a/.github/workflows/build-agent.yml b/.github/workflows/build-agent.yml index d515a725..3d3b775e 100644 --- a/.github/workflows/build-agent.yml +++ b/.github/workflows/build-agent.yml @@ -434,8 +434,16 @@ jobs: # 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). # Runs AFTER version.json so its bootstrapVersion read sees this build's manifest. + # Same signing identity as the binaries above: the loader on every device + # verifies the publisher of the script it downloads, so the publisher refuses + # to upload unsigned scripts. ./.github/scripts/Publish-BootstrapScripts.ps1 ` - -LegacySasToken '${{ secrets.AZURE_BLOB_AGENT_LEGACY_SAS_TOKEN }}' + -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 } # Update AdminConfiguration LatestAgentV2* -- ONLY on stable cutover. The hash-oracle diff --git a/.github/workflows/publish-scripts.yml b/.github/workflows/publish-scripts.yml index 789b841e..f964764e 100644 --- a/.github/workflows/publish-scripts.yml +++ b/.github/workflows/publish-scripts.yml @@ -35,6 +35,16 @@ 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 @@ -46,6 +56,37 @@ jobs: 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. @@ -56,10 +97,38 @@ jobs: 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 diff --git a/internal b/internal index 702dbb53..473df395 160000 --- a/internal +++ b/internal @@ -1 +1 @@ -Subproject commit 702dbb53dca97d5f3e80f0a8dbd8619f273a8de5 +Subproject commit 473df39529dca56a33382c7fda905d02302e4d37 diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 new file mode 100644 index 00000000..0c5fe59f --- /dev/null +++ b/scripts/Bootstrap/Start-AutopilotMonitor.Tests.ps1 @@ -0,0 +1,127 @@ +# Pester tests (Pester 5+) for the stage-1 loader Start-AutopilotMonitor.ps1. +# +# The script under test is dot-sourced; its entry guard prevents the loader from +# executing, so loading it here is side-effect free. Network and signature checks +# are mocked -- what is verified is the DECISION: what does the loader accept, what +# does it refuse, and how often does it retry. +# +# Run under Windows PowerShell 5.1 to match the IME runtime: +# powershell.exe -NoProfile -Command "& { $c = New-PesterConfiguration; $c.Run.Path = 'scripts\Bootstrap'; $c.Output.Verbosity = 'Detailed'; Invoke-Pester -Configuration $c }" +# +# This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). + +BeforeAll { + . (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') + + function New-FakeSignature { + param([string]$Status, [string]$Subject) + $cert = if ($Subject) { + [pscustomobject]@{ Subject = $Subject; Thumbprint = '0123456789ABCDEF' } + } else { $null } + return [pscustomobject]@{ Status = $Status; SignerCertificate = $cert } + } +} + +Describe 'Test-BootstrapSignature' { + BeforeEach { + Mock Write-Log { } + } + + It 'accepts a valid signature from the expected publisher' { + Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } + Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeTrue + } + + It 'refuses an unsigned script' { + Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'NotSigned' -Subject $null } + Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse + } + + It 'refuses a tampered script (hash mismatch)' { + Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'HashMismatch' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } + Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse + } + + It 'refuses a signature whose chain does not validate' { + Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'UnknownError' -Subject 'CN=glueckkanja AG, O=glueckkanja AG, C=DE' } + Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse + } + + # The whole point of the pinned publisher: a validly signed script from someone else + # is exactly what an attacker with any code-signing certificate can produce. + It 'refuses a valid signature from a different publisher' { + Mock Get-AuthenticodeSignature { New-FakeSignature -Status 'Valid' -Subject 'CN=Somebody Else, O=Somebody Else Ltd, C=US' } + Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse + } + + It 'refuses when the signature check itself fails' { + Mock Get-AuthenticodeSignature { throw 'file not found' } + Test-BootstrapSignature -Path 'TestDrive:\x.ps1' | Should -BeFalse + } +} + +Describe 'Get-VerifiedBootstrapScript' { + BeforeEach { + Mock Write-Log { } + Mock Start-Sleep { } + Mock Invoke-WebRequest { } + } + + It 'returns after the first attempt when the download verifies' { + Mock Test-BootstrapSignature { $true } + Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue + Should -Invoke Invoke-WebRequest -Times 1 -Exactly + } + + It 'gives up after three attempts when verification keeps failing' { + Mock Test-BootstrapSignature { $false } + Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + } + + # A truncated or proxy-mangled body fails verification, so the retry has to cover the + # download AND the check, not just the transport. + It 'retries a failed verification and accepts a later attempt' { + $script:calls = 0 + Mock Test-BootstrapSignature { $script:calls++; return ($script:calls -ge 2) } + Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue + Should -Invoke Invoke-WebRequest -Times 2 -Exactly + } + + It 'retries a transport failure' { + $script:downloads = 0 + Mock Invoke-WebRequest { $script:downloads++; if ($script:downloads -eq 1) { throw 'connection reset' } } + Mock Test-BootstrapSignature { $true } + Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeTrue + Should -Invoke Invoke-WebRequest -Times 2 -Exactly + } + + It 'never executes anything when no attempt verifies' { + Mock Test-BootstrapSignature { $false } + Get-VerifiedBootstrapScript -Url 'https://example.invalid/s.ps1' -Destination 'TestDrive:\s.ps1' | Should -BeFalse + } +} + +Describe 'publish contract' { + BeforeAll { + $script:loaderText = Get-Content (Join-Path $PSScriptRoot 'Start-AutopilotMonitor.ps1') -Raw + } + + # Publish-BootstrapScripts.ps1 renders the -Dev loader by substituting this exact + # literal and fails the publish when it is gone. Catching that here means the PR fails + # instead of the publish. + It 'carries the dev-render anchor the publisher substitutes' { + $anchor = '$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1"' + $script:loaderText.Contains($anchor) | Should -BeTrue + } + + # The publisher parses this to write version.json.loaderVersion and to run the + # version-bump guard. + It 'declares a parsable ScriptVersion' { + $script:loaderText -match '\$ScriptVersion\s*=\s*"([\d\.\-a-zA-Z]+)"' | Should -BeTrue + } + + It 'pins the expected publisher by subject, not by thumbprint' { + $script:loaderText -match '\$ExpectedPublisher\s*=\s*"\*O=' | Should -BeTrue + } +} diff --git a/scripts/Bootstrap/Start-AutopilotMonitor.ps1 b/scripts/Bootstrap/Start-AutopilotMonitor.ps1 new file mode 100644 index 00000000..fb4df167 --- /dev/null +++ b/scripts/Bootstrap/Start-AutopilotMonitor.ps1 @@ -0,0 +1,197 @@ +<# +.SYNOPSIS + Stage 1 of the Autopilot Monitor bootstrap: fetches the current installer and runs it. + +.DESCRIPTION + This is the file an administrator assigns in Intune. It carries no enrollment logic at + all: it downloads the published Install-AutopilotMonitor.ps1, proves that it was signed + by Autopilot Monitor, and runs it in this process. + + Why two stages: the installer holds every guard, relax rule and download path, and those + change. Whoever assigns the installer directly has to replace their Intune copy each + time; whoever assigns this loader never does, because the part that changes lives on the + server. Assigning the installer directly stays supported and unchanged. + + This file is deliberately boring. It is the one piece that cannot be fixed remotely, so + it does the least it can: download, verify, invoke, log. + + Verification is by Authenticode publisher, not by hash. A hash oracle would have to be + fetched from the same host as the script itself, so it could not survive a compromise of + that host, while the expected publisher below travels with this file. It pins the + certificate SUBJECT and not a thumbprint, so a certificate renewal does not brick a + loader that is already deployed on devices. + + Fail-soft: the loader always exits 0 and never throws into the caller. A device that + cannot reach the download endpoint is a device without monitoring, never a failed + enrollment step. Verification failures are the same: nothing runs, and the reason is in + the log. Refusing to execute unverified code as SYSTEM is the point of this file. + + This file MUST remain pure ASCII (PS 5.1 reads BOM-less files as ANSI). + +.PARAMETER BootstrapUrl + Installer URL. Defaults to the published stable installer; the -Dev variant of this + loader is rendered from that default at publish time. + +.PARAMETER BootstrapArguments + Everything else is passed through to the installer unchanged, so any parameter the + installer accepts can be set here without this file knowing about it. + +.EXAMPLE + .\Start-AutopilotMonitor.ps1 + +.EXAMPLE + .\Start-AutopilotMonitor.ps1 -MaxBootstrapWindowHours 24 + +.NOTES + Author : Oliver Kieselbach (autopilotmonitor.com) + Runtime : Windows PowerShell 5.1, SYSTEM context, during Autopilot enrollment + +.CHANGELOG + 2026-09-08 v1.0 Initial version: two-stage bootstrap, publisher-pinned verification. +#> + +[CmdletBinding()] +param( + [Parameter(Mandatory = $false)] + [string]$BootstrapUrl = "https://download.autopilotmonitor.com/agent/Install-AutopilotMonitor.ps1", + + [Parameter(Mandatory = $false, ValueFromRemainingArguments = $true)] + [string[]]$BootstrapArguments +) + +# Script version (bump on meaningful changes; see .CHANGELOG above) +$ScriptVersion = "1.0" + +# The publisher every downloaded stage-2 script must carry. A -like pattern on the subject, +# NOT a thumbprint: certificates are renewed, deployed loaders are not. +$ExpectedPublisher = "*O=glueckkanja AG*" + +$LoaderBasePath = "$env:ProgramData\AutopilotMonitor" +$LoaderLogPath = "$LoaderBasePath\Logs" +$LogFile = "$LoaderLogPath\bootstrap-loader.log" +$BootstrapPath = "$LoaderBasePath\Install-AutopilotMonitor.ps1" + +$DownloadAttempts = 3 +$RetryDelaySeconds = 10 + +# Write-Host as well as the file: the Intune script output is what an administrator sees in +# the portal, the file is what a support case reads. +function Write-Log { + param([string]$Message) + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $logMessage = "[$timestamp] $Message" + Write-Host $logMessage + Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue +} + +# True only when the file is signed, the signature validates against the machine's trust +# chain, and the signer is us. Any error is a false -- an unreadable signature is an +# unverified script. +function Test-BootstrapSignature { + param([string]$Path) + + try { + $sig = Get-AuthenticodeSignature -FilePath $Path -ErrorAction Stop + } + catch { + Write-Log "Signature check failed to run: $($_.Exception.Message)" + return $false + } + + if ($null -eq $sig) { + Write-Log "Signature check returned nothing." + return $false + } + + $subject = if ($sig.SignerCertificate) { $sig.SignerCertificate.Subject } else { "" } + Write-Log "Signature status: $($sig.Status). Signer: $subject" + + if ($sig.Status -ne 'Valid') { + Write-Log "REJECTED: expected signature status 'Valid'." + return $false + } + if ($subject -notlike $ExpectedPublisher) { + Write-Log "REJECTED: signer does not match the expected publisher." + return $false + } + + Write-Log "Signature accepted (thumbprint $($sig.SignerCertificate.Thumbprint))." + return $true +} + +# Downloads the installer and returns $true once a downloaded copy passes verification. +# Verification lives inside the retry loop on purpose: a truncated or proxy-mangled body +# fails the signature check, and that is worth another attempt. +function Get-VerifiedBootstrapScript { + param([string]$Url, [string]$Destination) + + for ($attempt = 1; $attempt -le $DownloadAttempts; $attempt++) { + try { + Write-Log "Download attempt $attempt/$DownloadAttempts from $Url" + Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing -TimeoutSec 60 -ErrorAction Stop + + if (Test-BootstrapSignature -Path $Destination) { + return $true + } + } + catch { + Write-Log "Download attempt $attempt failed: $($_.Exception.Message)" + } + + if ($attempt -lt $DownloadAttempts) { + Start-Sleep -Seconds $RetryDelaySeconds + } + } + + return $false +} + +# Main flow. Only invoked when the script is run directly (see entry guard at the bottom), +# so the Pester suite can dot-source this file without bootstrapping anything. +function Start-BootstrapLoader { + New-Item -ItemType Directory -Force -Path $LoaderLogPath -ErrorAction SilentlyContinue | Out-Null + + Write-Log "=== Autopilot Monitor bootstrap loader v$ScriptVersion started (user=$env:USERNAME, pid=$PID) ===" + + # PS 5.1 on an OOBE device still defaults to TLS 1.0 for this process. + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 + } + catch { + Write-Log "INFO: could not raise the TLS protocol: $($_.Exception.Message)" + } + + if (-not (Get-VerifiedBootstrapScript -Url $BootstrapUrl -Destination $BootstrapPath)) { + Write-Log "No verified installer available after $DownloadAttempts attempt(s). Nothing was executed." + return + } + + Write-Log "Executing $BootstrapPath" + + # Same process, so the execution policy this loader runs under applies to stage 2 as + # well and no second policy decision can strand the installer. Splatting an array of + # remaining arguments keeps this file free of any knowledge about the installer's + # parameters. + $arguments = @() + if ($BootstrapArguments) { $arguments = $BootstrapArguments } + + try { + & $BootstrapPath @arguments + Write-Log "Installer finished (exit code $LASTEXITCODE)." + } + catch { + Write-Log "Installer threw: $($_.Exception.Message)" + } +} + +# Entry guard: dot-sourcing (Pester) loads the functions without running the loader. +if ($MyInvocation.InvocationName -ne '.') { + try { + Start-BootstrapLoader + } + catch { + # Nothing above should throw; if it does, it must not become a failed Intune script. + Write-Log "Loader failed: $($_.Exception.Message)" + } + exit 0 +}