<# .SYNOPSIS Bootstrap script to deploy and start the Autopilot Monitor agent. .DESCRIPTION This script is designed to be deployed via Intune as a PowerShell Script during Autopilot. It runs VERY EARLY in the enrollment process (first Intune action) and: 1. Creates a unique session ID for this enrollment 2. Downloads the monitoring agent binaries 3. Verifies integrity via SHA-256 hash from version.json 4. Installs agent binaries (agent uses built-in backend URL or CLI override) 5. Registers agent as Scheduled Task (runs on computer startup) 6. Agent self-destructs when enrollment completes INTEGRITY VERIFICATION: The script downloads version.json from the same blob container as the agent ZIP. If version.json contains a "sha256" field, the downloaded ZIP is verified against it using SHA-256. This prevents tampering during download (MITM) and detects corrupted downloads. If the hash does not match, installation is aborted. For backward compatibility: if version.json does not contain a "sha256" field (older builds), the script falls back to the legacy Content-MD5 header check. .PARAMETER AgentDownloadUrl URL to download the agent binaries from (ZIP file) .EXAMPLE .\Install-AutopilotMonitor.ps1 (Uses built-in backend URL from the agent) .NOTES - Agent is temporary and auto-removes after enrollment - Everything in C:\ProgramData\AutopilotMonitor (easy cleanup) - No Registry entries (clean machine after removal) - Scheduled Task survives reboots during enrollment - SHA-256 integrity verification since v1.0.706+ - IMPORTANT: This script MUST remain pure ASCII (no Unicode/UTF-8 special chars). PowerShell 5.1 (IME) reads scripts without BOM as ANSI, corrupting multi-byte chars. .CHANGELOG 2026-03-30 Fixed non-ASCII characters (em-dashes, Unicode symbols) that broke script parsing under PowerShell 5.1 / IME AgentExecutor 2026-03-29 Hardened integrity check: SHA-256 verification via version.json 2026-02-16 Extended OS age default threshold 2026-02-13 Simplified bootstrapper, introduced --install parameter for agent 2026-02-12 More robust download with integrity check (Content-MD5) 2026-02-12 Enhanced pre-flight checks, boot time support 2026-02-12 Added pre-flight check: skip if agent already installed 2026-02-05 Initial version #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$AgentDownloadUrl = "https://autopilotmonitor.blob.core.windows.net/agent/AutopilotMonitor-Agent.zip", [Parameter(Mandatory = $false)] [int]$MaxOsAgeMinutes = 120 ) # Configuration - Everything in ProgramData for easy cleanup $AgentBasePath = "$env:ProgramData\AutopilotMonitor" $AgentBinPath = "$AgentBasePath\Agent" $AgentLogPath = "$AgentBasePath\Logs" $LogFile = "$AgentLogPath\bootstrap_agent.log" # Ensure directories exist New-Item -Path $AgentBasePath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null New-Item -Path $AgentBinPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null New-Item -Path $AgentLogPath -ItemType Directory -Force -ErrorAction SilentlyContinue | Out-Null function Write-Log { param([string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $logMessage = "[$timestamp] $Message" Write-Output $logMessage Add-Content -Path $LogFile -Value $logMessage -ErrorAction SilentlyContinue } function Get-FileMd5Base64 { param( [Parameter(Mandatory = $true)] [string]$Path ) $md5 = [System.Security.Cryptography.MD5]::Create() try { $stream = [System.IO.File]::OpenRead($Path) try { $hashBytes = $md5.ComputeHash($stream) } finally { $stream.Dispose() } return [Convert]::ToBase64String($hashBytes) } finally { $md5.Dispose() } } try { Write-Log "===== Autopilot Monitor Bootstrap Started =====" # -- Pre-flight: Skip installation on already-enrolled devices -- # Check 1: OS install date must be within threshold (fresh device) # TODO: issue is the OS might got installed a long time ago but the OOBE just started now (e.g. old image, or re-flash). # Ideally we want to detect the OOBE start time... need to investigate if there's a reliable way to get that (event log? registry? WMI?). # For now we just check OS install date as a proxy, which should work in most cases except for old images or re-flashed devices. $osInstallDate = (Get-CimInstance Win32_OperatingSystem).InstallDate $osAge = (Get-Date) - $osInstallDate $osAgeMinutesRounded = [int][Math]::Round($osAge.TotalMinutes) Write-Log "OS install date: $osInstallDate (age: ${osAgeMinutesRounded}m)" if ($osAge.TotalMinutes -gt $MaxOsAgeMinutes) { Write-Log "SKIP: OS was installed ${osAgeMinutesRounded}m ago (threshold: ${MaxOsAgeMinutes}m). Device is not freshly provisioned." exit 0 } # Check 2: MDM enrollment must not be completed yet $enrollmentPath = "HKLM:\SOFTWARE\Microsoft\Enrollments" $mdmEnrolled = $false if (Test-Path $enrollmentPath) { $enrollmentEntries = Get-ChildItem -Path $enrollmentPath -ErrorAction SilentlyContinue | Where-Object { $_.PSChildName -match '^[0-9A-Fa-f\-]{36}$' } foreach ($entry in $enrollmentEntries) { $providerID = (Get-ItemProperty -Path $entry.PSPath -Name "ProviderID" -ErrorAction SilentlyContinue).ProviderID if ($providerID) { $mdmEnrolled = $true Write-Log "Found MDM enrollment: ProviderID=$providerID (EnrollmentID=$($entry.PSChildName))" break } } } if ($mdmEnrolled) { # Double-check: Is enrollment status tracking showing completed? $espPath = "HKLM:\SOFTWARE\Microsoft\Windows\Autopilot\EnrollmentStatusTracking\Device\Setup" $hasCompleted = (Get-ItemProperty -Path $espPath -Name "HasProvisioningCompleted" -ErrorAction SilentlyContinue).HasProvisioningCompleted if ($hasCompleted -eq 1) { Write-Log "SKIP: Autopilot enrollment already completed (HasProvisioningCompleted=1). Device is already enrolled." exit 0 } } # Check 3: Is the agent already installed? (leftover from previous run) if (Test-Path $AgentBinPath) { $existingAgent = Get-ChildItem -Path $AgentBinPath -Filter "AutopilotMonitor.Agent.exe" -ErrorAction SilentlyContinue if ($existingAgent) { Write-Log "SKIP: Agent already installed at $($existingAgent.FullName). Previous enrollment may have been in progress. Please check if enrollment completed or clean up C:\ProgramData\AutopilotMonitor before re-running." exit 0 } } Write-Log "Pre-flight checks passed - device is freshly provisioned and enrollment in progress" # Download and extract agent binaries $agentExePath = Join-Path $AgentBinPath "AutopilotMonitor.Agent.exe" if (Test-Path $agentExePath) { Write-Log "Agent already installed at $agentExePath" } else { Write-Log "Downloading agent from $AgentDownloadUrl..." try { # Derive version.json URL from the agent download URL (same blob container) $versionJsonUrl = $AgentDownloadUrl -replace '[^/]+$', 'version.json' $expectedSha256 = $null # Download version.json for SHA-256 integrity verification try { Write-Log "Fetching version.json from $versionJsonUrl for integrity verification..." $versionJsonResponse = Invoke-RestMethod -Uri $versionJsonUrl -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop if ($versionJsonResponse.sha256) { $expectedSha256 = $versionJsonResponse.sha256.ToLowerInvariant() Write-Log "SHA-256 hash from version.json: $expectedSha256 (version: $($versionJsonResponse.version))" } else { Write-Log "version.json has no sha256 field - falling back to legacy MD5 check (older build)" } } catch { Write-Log "WARNING: Could not fetch version.json - falling back to legacy MD5 check: $($_.Exception.Message)" } # Download agent ZIP $zipPath = Join-Path $env:TEMP "AutopilotMonitor-Agent.zip" $maxDownloadAttempts = 3 $downloadAttempt = 0 $downloadResponse = $null do { $downloadAttempt++ try { Write-Log "Download attempt ${downloadAttempt}/${maxDownloadAttempts}" $downloadResponse = Invoke-WebRequest ` -Uri $AgentDownloadUrl ` -OutFile $zipPath ` -UseBasicParsing ` -TimeoutSec 30 ` -ErrorAction Stop ` -PassThru Write-Log "Downloaded agent to $zipPath" break } catch { if ($downloadAttempt -ge $maxDownloadAttempts) { throw } $retryDelaysInSeconds = @(2, 4, 8) $retryDelaySeconds = $retryDelaysInSeconds[$downloadAttempt - 1] Write-Log "Download failed (attempt $downloadAttempt): $($_.Exception.Message). Retrying in ${retryDelaySeconds}s..." Start-Sleep -Seconds $retryDelaySeconds } } while ($downloadAttempt -lt $maxDownloadAttempts) # Integrity check: SHA-256 (preferred) or legacy Content-MD5 (fallback) if ($expectedSha256) { $actualSha256 = (Get-FileHash $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() Write-Log "Validating SHA-256 hash against version.json" if ($actualSha256 -ne $expectedSha256) { throw "SHA-256 integrity check FAILED. Expected='$expectedSha256', Actual='$actualSha256'. Download may be tampered or corrupted." } Write-Log "SHA-256 integrity check passed" } else { # Legacy fallback: Content-MD5 header check $expectedMd5Header = $downloadResponse.Headers["Content-MD5"] $expectedMd5 = if ($expectedMd5Header -is [System.Array]) { "$($expectedMd5Header[0])".Trim() } else { "$expectedMd5Header".Trim() } if ($expectedMd5 -notmatch '\S') { Write-Log "WARNING: No SHA-256 and no Content-MD5 header - skipping integrity validation" } else { $actualMd5 = Get-FileMd5Base64 -Path $zipPath Write-Log "Validating Content-MD5 header against downloaded ZIP" if ($actualMd5 -ne $expectedMd5) { throw "MD5 integrity check failed. Expected (Content-MD5)='$expectedMd5', Actual='$actualMd5'" } Write-Log "MD5 integrity check passed" } } # Extract to agent bin path Expand-Archive -Path $zipPath -DestinationPath $AgentBinPath -Force Write-Log "Extracted agent to $AgentBinPath" # Cleanup Remove-Item $zipPath -Force -ErrorAction SilentlyContinue Write-Log "Cleaned up temporary files" # Verify extraction if (-not (Test-Path $agentExePath)) { throw "Agent executable not found after extraction at $agentExePath" } Write-Log "Agent installation completed successfully" } catch { Write-Log "ERROR downloading/extracting agent: $($_.Exception.Message)" throw } } # Let the agent install/deploy itself and manage its own Scheduled Task Write-Log "Calling agent install mode (--install)..." & $agentExePath --install $installExitCode = $LASTEXITCODE if ($installExitCode -ne 0) { throw "Agent install failed with exit code $installExitCode" } Write-Log "Agent install mode completed successfully" Write-Log "===== Bootstrap Completed Successfully =====" Write-Log "Agent Path: $AgentBasePath" Write-Log "Scheduled Task: managed by agent (--install)" Write-Log "Agent will monitor enrollment and auto-remove when complete" Write-Log "Bootstrap log: $LogFile" Write-Log "" Write-Log "IMPORTANT: Agent runs as Scheduled Task and will:" Write-Log " - Generate unique session ID on startup" Write-Log " - Survive reboots during enrollment" Write-Log " - Monitor enrollment phases in real-time" Write-Log " - Upload events to built-in backend URL" Write-Log " - Self-destruct when enrollment completes" exit 0 } catch { Write-Log "===== Bootstrap FAILED =====" Write-Log "ERROR: $($_.Exception.Message)" Write-Log "Stack trace: $($_.ScriptStackTrace)" Write-Log "Please check log file: $LogFile" exit 1 }