PublicBootstrap.psm1
|
<#
.SYNOPSIS Secure bootstrap – downloads and executes private scripts/workflows. Bootstraps PS7 from Azure Blob if running under PS5.1. Uses AzCopy with PSCRED as per MS official guidance. Graph token fetched on‑demand with multiple fallbacks. Manifest uses "ScriptHashes" with full filenames (including .ps1). Version: 10.103 (Graph scope on login, multiple fallbacks) #> Set-StrictMode -Version Latest $ManifestPublicKeyXml = @" <RSAKeyValue><Modulus>6JcAAw1jwc5n8O6ADX+QEgPZo05r0KFpPoXIWMn62JP6IacmJr8XFXawU2q8mBI0GYGPQzXEDPT2dUYbCEAvGnCaEFefNGmhlQmA7dwSWE8zuO2sMFnd90SkE6y7c0/VeBUUaw7B/A//Hm2lQY4vke3Y4ZVupXS8itMf6ILP/ay4MkvhB/+LBMISn/vkCLGdsaDaVCpWBWvV4PO48wso3072V1Gh8m60x7eUhSDLfQu25m9vBnnZ2W3xr8N1I9f7gcwb54XP97oXmlw98A/B2N/648hHs/Daim6VJ4YXaSwPzsEyybdqljC325Eb90omJFof6ZmmW1kk923Ph47mLQ==</Modulus><Exponent>AQAB</Exponent></RSAKeyValue> "@ $script:ModulePath = $MyInvocation.MyCommand.Path if (-not $script:ModulePath) { $script:ModulePath = $PSCommandPath } $script:LogDir = "C:\Windows\Temp\WorkflowLogs" $script:EventLogFile = Join-Path $script:LogDir "WorkflowEvents.log" $script:TranscriptFile = Join-Path $script:LogDir "WorkflowTranscript.log" $script:BootstrapErrorLog = Join-Path $script:LogDir "bootstrap_error.log" function Write-WorkflowEvent { param([string]$Level = "INFO", [string]$Message) $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $entry = "[$timestamp] [$Level] $Message" Write-Host $entry -ForegroundColor $(if ($Level -eq "ERROR") { "Red" } elseif ($Level -eq "WARNING") { "Yellow" } else { "Gray" }) try { if (-not (Test-Path $script:LogDir)) { New-Item -ItemType Directory -Path $script:LogDir -Force -ErrorAction Stop | Out-Null } Add-Content -Path $script:EventLogFile -Value $entry -Force -ErrorAction Stop } catch { } } # ---------- Helper functions ---------- function Test-IsElevated { $id = [Security.Principal.WindowsIdentity]::GetCurrent() if ($id.User.Value -eq 'S-1-5-18') { return $true } $p = New-Object Security.Principal.WindowsPrincipal($id) return $p.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } function Install-AzCopy { $tempDir = Join-Path $env:TEMP "AzCopyInstall" New-Item -ItemType Directory -Path $tempDir -Force -ErrorAction SilentlyContinue | Out-Null $zipPath = Join-Path $tempDir "azcopy.zip" Write-Host "Downloading AzCopy..." -ForegroundColor Yellow try { Invoke-WebRequest -Uri "https://aka.ms/downloadazcopy-v10-windows" -OutFile $zipPath -UseBasicParsing -ErrorAction Stop Expand-Archive -Path $zipPath -DestinationPath $tempDir -Force $exe = Get-ChildItem -Path $tempDir -Recurse -File -Filter "azcopy.exe" -ErrorAction Stop | Select-Object -First 1 $targetDir = "$env:ProgramFiles\AzCopy" New-Item -ItemType Directory -Path $targetDir -Force -ErrorAction SilentlyContinue | Out-Null Copy-Item -Path $exe.FullName -Destination "$targetDir\azcopy.exe" -Force return "$targetDir\azcopy.exe" } catch { Write-Warning "AzCopy install failed: $_" return $null } } function Invoke-AzCopyWithPSCRED { param($SourceUrl, $DestinationPath, $TenantId) $azCopyPath = $null $cmd = Get-Command azcopy -ErrorAction SilentlyContinue if ($cmd) { $azCopyPath = $cmd.Source } if (-not $azCopyPath -or -not (Test-Path $azCopyPath)) { $azCopyPath = (Get-ChildItem "C:\Program Files\AzCopy\azcopy.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).FullName } if (-not $azCopyPath -or -not (Test-Path $azCopyPath)) { $azCopyPath = (Get-ChildItem "C:\azcopy\azcopy.exe" -ErrorAction SilentlyContinue | Select-Object -First 1).FullName } if (-not $azCopyPath -or -not (Test-Path $azCopyPath)) { $azCopyPath = Install-AzCopy if (-not $azCopyPath) { return $false } } $version = & $azCopyPath --version 2>$null if ($LASTEXITCODE -ne 0 -or $version -notmatch '10\.') { return $false } Remove-Item Env:AZCOPY_AUTO_LOGIN_TYPE -ErrorAction SilentlyContinue Remove-Item Env:AZCOPY_TENANT_ID -ErrorAction SilentlyContinue Write-WorkflowEvent -Level "INFO" -Message "AzCopy downloading from URL: $SourceUrl" Write-WorkflowEvent -Level "INFO" -Message "AzCopy destination: $DestinationPath" $env:AZCOPY_AUTO_LOGIN_TYPE = "PSCRED" if ($TenantId) { $env:AZCOPY_TENANT_ID = $TenantId } Write-WorkflowEvent -Level "INFO" -Message "AzCopy using PSCRED with tenant $TenantId" $output = & $azCopyPath copy $SourceUrl $DestinationPath 2>&1 $exit = $LASTEXITCODE Remove-Item Env:AZCOPY_AUTO_LOGIN_TYPE -ErrorAction SilentlyContinue Remove-Item Env:AZCOPY_TENANT_ID -ErrorAction SilentlyContinue if ($exit -ne 0) { Write-WorkflowEvent -Level "ERROR" -Message "AzCopy failed (exit $exit): $($output -join '; ')" return $false } return $true } function Find-PS7ZipBlob { param($StorageAccount, $StorageToken, $ContainerName = "ps7") $url = "https://${StorageAccount}.blob.core.windows.net/${ContainerName}?restype=container&comp=list" try { $headers = @{ "Authorization" = "Bearer $StorageToken"; "x-ms-version" = "2023-08-03" } $resp = Invoke-WebRequest -Uri $url -Headers $headers -UseBasicParsing -ErrorAction Stop $content = $resp.Content $matches = [regex]::Matches($content, '<Name>(.*?)</Name>') foreach ($m in $matches) { $name = $m.Groups[1].Value if ($name -match '\.zip$') { return "https://${StorageAccount}.blob.core.windows.net/${ContainerName}/${name}" } } } catch { Write-WorkflowEvent -Level "ERROR" -Message "Find PS7 zip failed: $_" } return $null } function Ensure-PowerShell7 { param($TenantFQDN, $StorageAccount, $AuthMethod, $Ps7Container = "ps7") if ($PSVersionTable.PSVersion.Major -ge 7) { return $true } Write-WorkflowEvent -Level "INFO" -Message "PS7 not detected. Installing from Azure Blob..." $installDir = Join-Path $env:LOCALAPPDATA "Programs\PowerShell\7" $pwshExe = Join-Path $installDir "pwsh.exe" if (Test-Path $pwshExe) { $env:Path = "$installDir;$env:Path" return $true } Write-WorkflowEvent -Level "INFO" -Message "Authenticating to get storage token..." $authSuccess = Initialize-AzAuthentication -TenantFQDN $TenantFQDN -AuthMethod $AuthMethod if (-not $authSuccess) { return $false } try { $obj = Get-AzAccessToken -ResourceUrl "https://storage.azure.com/" -ErrorAction Stop $raw = $obj.Token if ($raw -is [Security.SecureString]) { $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($raw) try { $token = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } else { $token = [string]$raw } $token = $token.Trim() } catch { Write-WorkflowEvent -Level "ERROR" -Message "Storage token failed: $_"; return $false } $zipUrl = Find-PS7ZipBlob -StorageAccount $StorageAccount -StorageToken $token -ContainerName $Ps7Container if (-not $zipUrl) { Write-WorkflowEvent -Level "ERROR" -Message "No PS7 zip found."; return $false } $tempZip = Join-Path $env:TEMP "pwsh7_$(Get-Random).zip" Write-WorkflowEvent -Level "INFO" -Message "Downloading PS7 from $zipUrl..." try { $headers = @{ "Authorization" = "Bearer $token"; "x-ms-version" = "2023-08-03" } Invoke-WebRequest -Uri $zipUrl -Headers $headers -OutFile $tempZip -UseBasicParsing -ErrorAction Stop } catch { Write-WorkflowEvent -Level "ERROR" -Message "Download PS7 failed: $_"; return $false } if (-not (Test-Path $tempZip) -or (Get-Item $tempZip).Length -eq 0) { return $false } Write-WorkflowEvent -Level "INFO" -Message "Extracting PS7 to $installDir..." try { if (Test-Path $installDir) { Remove-Item -Path $installDir -Recurse -Force -ErrorAction Stop } Expand-Archive -Path $tempZip -DestinationPath $installDir -Force -ErrorAction Stop if (-not (Test-Path $pwshExe)) { return $false } $env:Path = "$installDir;$env:Path" Write-WorkflowEvent -Level "INFO" -Message "PS7 installed." } catch { Write-WorkflowEvent -Level "ERROR" -Message "Extract PS7 failed: $_"; return $false } finally { Remove-Item $tempZip -Force -ErrorAction SilentlyContinue } return $true } function Invoke-WithPowerShell7 { param($ScriptPath, [hashtable]$ParamHash) $pwshExe = (Get-Command pwsh -ErrorAction SilentlyContinue).Source if (-not $pwshExe) { $pwshExe = Join-Path $env:LOCALAPPDATA "Programs\PowerShell\7\pwsh.exe" } if (-not (Test-Path $pwshExe)) { throw "pwsh.exe not found." } $tempScript = Join-Path $env:TEMP "WorkflowBootstrap.ps1" $logDir = $script:LogDir $eventLogFile = $script:EventLogFile $transcriptFile = $script:TranscriptFile $bootstrapErrorLog = $script:BootstrapErrorLog $cleanParams = @{} foreach ($k in $ParamHash.Keys) { $v = $ParamHash[$k] if ($v -is [System.Management.Automation.SwitchParameter]) { $v = [bool]$v } $cleanParams[$k] = $v } $paramsJson = $cleanParams | ConvertTo-Json -Compress -Depth 10 $paramsBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($paramsJson)) $scriptContent = @' $ErrorActionPreference = 'Stop' $logDir = '__LOGDIR__' $eventLogFile = '__EVENTLOGFILE__' $transcriptFile = '__TRANSCRIPTFILE__' $bootstrapErrorLog = '__BOOTSTRAPERRORLOG__' $paramsBase64 = '__PARAMS_BASE64__' $ScriptPath = '__SCRIPTPATH__' New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null try { Start-Transcript -Path $transcriptFile -Append -Force -ErrorAction SilentlyContinue } catch { Add-Content -Path $bootstrapErrorLog -Value "[$(Get-Date)] Transcript start failed: $_" -Force } try { Import-Module $ScriptPath -Force -ErrorAction Stop $params = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($paramsBase64)) | ConvertFrom-Json -AsHashtable if ($params.ContainsKey('KeepTempFiles') -and $params['KeepTempFiles'] -isnot [bool]) { $params['KeepTempFiles'] = [bool]$params['KeepTempFiles'] } Start-Workflow @params } catch { $entry = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ERROR: $_`n$($_.ScriptStackTrace)" Add-Content -Path $eventLogFile -Value $entry -Force Add-Content -Path $bootstrapErrorLog -Value $entry -Force } finally { try { Stop-Transcript -ErrorAction SilentlyContinue } catch {} } '@ $scriptContent = $scriptContent -replace '__LOGDIR__', $logDir ` -replace '__EVENTLOGFILE__', $eventLogFile ` -replace '__TRANSCRIPTFILE__', $transcriptFile ` -replace '__BOOTSTRAPERRORLOG__', $bootstrapErrorLog ` -replace '__PARAMS_BASE64__', $paramsBase64 ` -replace '__SCRIPTPATH__', $ScriptPath $scriptContent | Out-File $tempScript -Encoding UTF8 -Force Write-WorkflowEvent -Level "INFO" -Message "Relaunching to PS7..." $process = Start-Process -FilePath $pwshExe ` -ArgumentList @('-ExecutionPolicy', 'Bypass', '-File', $tempScript) ` -WindowStyle Hidden ` -Wait ` -PassThru if ($process.ExitCode -ne 0) { $err = if (Test-Path $script:BootstrapErrorLog) { Get-Content $script:BootstrapErrorLog -Raw } else { "No error log." } throw "Relaunch failed (exit $($process.ExitCode)): $err" } Remove-Item $tempScript -Force -ErrorAction SilentlyContinue } function Invoke-PowerShell7Bootstrap { param($ParamHash) Write-WorkflowEvent -Level "INFO" -Message "Bootstrap to PS7..." $Tenant = if ($ParamHash.ContainsKey('Tenant')) { $ParamHash['Tenant'] } else { $null } $StorageAccount = if ($ParamHash.ContainsKey('StorageAccount')) { $ParamHash['StorageAccount'] } else { $null } $AuthMethod = if ($ParamHash.ContainsKey('AuthMethod')) { $ParamHash['AuthMethod'] } else { "Interactive" } $Ps7Container = if ($ParamHash.ContainsKey('Ps7Container')) { $ParamHash['Ps7Container'] } else { "ps7" } $TenantFQDN = if ($Tenant -and $Tenant -match '\.onmicrosoft\.com$') { $Tenant } else { if ($Tenant) { "$Tenant.onmicrosoft.com" } else { $null } } if (-not (Ensure-PowerShell7 -TenantFQDN $TenantFQDN -StorageAccount $StorageAccount -AuthMethod $AuthMethod -Ps7Container $Ps7Container)) { throw "Failed to install PowerShell 7." } $scriptPath = $script:ModulePath if (-not $scriptPath -or -not (Test-Path $scriptPath)) { throw "Module path invalid." } Invoke-WithPowerShell7 -ScriptPath $scriptPath -ParamHash $ParamHash } function Initialize-AzAuthentication { param($TenantFQDN = $null, $AuthMethod = "Interactive") try { if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { Write-Host "Installing Az.Accounts..." Install-Module -Name Az.Accounts -Scope CurrentUser -Force -ErrorAction Stop } Import-Module Az.Accounts -Force -ErrorAction Stop Write-WorkflowEvent -Level "INFO" -Message "Disconnecting existing Azure session (if any)..." Disconnect-AzAccount -ErrorAction SilentlyContinue Write-WorkflowEvent -Level "INFO" -Message "Starting new Azure authentication ($AuthMethod)..." $connectParams = @{ ErrorAction = 'Stop' } if ($TenantFQDN) { $connectParams.Tenant = $TenantFQDN } if ($AuthMethod -eq "DeviceCode") { $connectParams.UseDeviceAuthentication = $true } # Try to connect with Graph scope (recommended) try { Write-WorkflowEvent -Level "INFO" -Message "Attempting Connect-AzAccount with -AuthScope 'https://graph.microsoft.com/.default'..." $connectParams.AuthScope = "https://graph.microsoft.com/.default" Connect-AzAccount @connectParams } catch { Write-WorkflowEvent -Level "WARNING" -Message "AuthScope login failed: $_" # Fallback: try -Resource (old style) try { Write-WorkflowEvent -Level "INFO" -Message "Falling back to Connect-AzAccount -Resource 'https://graph.microsoft.com/'..." $connectParams.Remove('AuthScope') $connectParams.Resource = "https://graph.microsoft.com/" Connect-AzAccount @connectParams } catch { Write-WorkflowEvent -Level "ERROR" -Message "Resource login failed: $_" # Final fallback: connect without resource/scope (storage-only) Write-WorkflowEvent -Level "INFO" -Message "Falling back to Connect-AzAccount without scope (storage only)." $connectParams.Remove('AuthScope') $connectParams.Remove('Resource') Connect-AzAccount @connectParams } } Write-WorkflowEvent -Level "INFO" -Message "Azure authentication succeeded." return $true } catch { Write-WorkflowEvent -Level "ERROR" -Message "Az auth failed: $_" return $false } } function Download-ManifestRaw { param($ManifestUrl, $TenantId) Write-WorkflowEvent -Level "INFO" -Message "Downloading manifest from URL: $ManifestUrl" $tmp = Join-Path $env:TEMP "manifest_$(Get-Random).json" if (Invoke-AzCopyWithPSCRED -SourceUrl $ManifestUrl -DestinationPath $tmp -TenantId $TenantId) { $raw = Get-Content -Path $tmp -Raw -Encoding UTF8 -ErrorAction Stop Remove-Item $tmp -Force -ErrorAction SilentlyContinue return $raw } return $null } function Download-PrivateScript { param($Url, $StorageToken, $DestinationPath) Write-WorkflowEvent -Level "INFO" -Message "Downloading script from URL: $Url" try { Invoke-WebRequest -Uri $Url -Headers @{ Authorization = "Bearer $StorageToken"; "x-ms-version" = "2023-08-03" } -OutFile $DestinationPath -UseBasicParsing -ErrorAction Stop Write-WorkflowEvent -Level "INFO" -Message "Downloaded $Url ($((Get-Item $DestinationPath).Length) bytes)" return $true } catch { Write-WorkflowEvent -Level "WARNING" -Message "Script download failed: $_" return $false } } function Copy-ObjectDeep { param($InputObject) $json = $InputObject | ConvertTo-Json -Depth 32 -Compress -ErrorAction Stop return $json | ConvertFrom-Json -ErrorAction Stop } function Convert-CanonicalObject { param($obj) if ($obj -is [PSCustomObject]) { $ht = @{} foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = Convert-CanonicalObject -obj $p.Value } $obj = $ht } if ($obj -is [System.Collections.IDictionary]) { $sorted = [ordered]@{} foreach ($k in ($obj.Keys | Sort-Object)) { $sorted[$k] = Convert-CanonicalObject -obj $obj[$k] } return $sorted } elseif ($obj -is [System.Collections.IEnumerable] -and $obj -isnot [string]) { $list = @() foreach ($item in $obj) { $list += ,(Convert-CanonicalObject -obj $item) } return $list } else { return $obj } } function Get-CanonicalJson { param($ManifestObject) try { $clone = Copy-ObjectDeep -InputObject $ManifestObject if ($clone -is [PSCustomObject]) { $clone.PSObject.Properties.Remove('Signature') } elseif ($clone -is [IDictionary]) { $clone.Remove('Signature') } $canon = Convert-CanonicalObject -obj $clone return ($canon | ConvertTo-Json -Depth 32 -Compress -ErrorAction Stop) } catch { Write-WorkflowEvent -Level "ERROR" -Message "Canonical JSON failed: $_" return $null } } function Test-ManifestSignature { param($ManifestJson, $CanonicalJson, $PublicKeyXml) $sig = $ManifestJson.Signature if (-not $sig) { return $false } try { $rsa = [System.Security.Cryptography.RSA]::Create() $rsa.FromXmlString($PublicKeyXml) $sigBytes = [Convert]::FromBase64String($sig) $data = [Text.Encoding]::UTF8.GetBytes($CanonicalJson) return $rsa.VerifyData($data, $sigBytes, [Security.Cryptography.HashAlgorithmName]::SHA256, [Security.Cryptography.RSASignaturePadding]::Pkcs1) } catch { Write-WorkflowEvent -Level "ERROR" -Message "Signature verification exception: $_" return $false } } function Get-UserIdentityFromToken { param($AccessToken) if (-not $AccessToken) { return $null } try { $parts = $AccessToken.Split('.') if ($parts.Count -ne 3) { return $null } $payload = $parts[1] $payload = $payload.PadRight($payload.Length + (4 - $payload.Length % 4) % 4, '=') $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload)) $claims = $json | ConvertFrom-Json -ErrorAction Stop $upn = $claims.upn if (-not $upn) { $upn = $claims.email } if (-not $upn) { $upn = $claims.preferred_username } if (-not $upn) { $upn = $claims.unique_name } return $upn } catch { return $null } } function Get-GraphToken { param($TenantId) try { Write-WorkflowEvent -Level "INFO" -Message "Acquiring Graph token for tenant $TenantId..." # Try AuthScope (newer) try { $obj = Get-AzAccessToken -AuthScope "https://graph.microsoft.com/.default" -TenantId $TenantId -ErrorAction Stop $raw = $obj.Token } catch { Write-WorkflowEvent -Level "WARNING" -Message "AuthScope method failed: $_" # Fallback: ResourceUrl try { Write-WorkflowEvent -Level "INFO" -Message "Falling back to ResourceUrl method..." $obj = Get-AzAccessToken -ResourceUrl "https://graph.microsoft.com/" -TenantId $TenantId -ErrorAction Stop $raw = $obj.Token } catch { Write-WorkflowEvent -Level "WARNING" -Message "ResourceUrl method failed: $_" # Final fallback: Resource (older) Write-WorkflowEvent -Level "INFO" -Message "Falling back to Resource method (old)..." $obj = Get-AzAccessToken -Resource "https://graph.microsoft.com/" -TenantId $TenantId -ErrorAction Stop $raw = $obj.Token } } if ($raw -is [Security.SecureString]) { $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($raw) try { $token = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } else { $token = [string]$raw } $token = $token.Trim() Write-WorkflowEvent -Level "INFO" -Message "Graph token acquired." return $token } catch { Write-WorkflowEvent -Level "WARNING" -Message "Graph token acquisition failed: $_" return $null } } function Invoke-PrivateScript { param($ScriptContent, $CsvPath, $InstallFolder, $UserUPN, $TimeoutSeconds) $iss = [System.Management.Automation.Runspaces.InitialSessionState]::CreateDefault2() $iss.LanguageMode = [System.Management.Automation.PSLanguageMode]::FullLanguage $runspace = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace($iss) $runspace.ApartmentState = [System.Threading.ApartmentState]::STA $runspace.ThreadOptions = [System.Management.Automation.Runspaces.PSThreadOptions]::ReuseThread $runspace.Open() $ps = [System.Management.Automation.PowerShell]::Create() $ps.Runspace = $runspace $scriptBytes = [Text.Encoding]::UTF8.GetBytes($ScriptContent) $scriptBase64 = [Convert]::ToBase64String($scriptBytes) $csvBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($CsvPath)) $installBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($InstallFolder)) $upnBase64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($UserUPN)) $wrapped = @" `$scriptBytes = [Convert]::FromBase64String('$scriptBase64') `$scriptContent = [Text.Encoding]::UTF8.GetString(`$scriptBytes) `$scriptBlock = [ScriptBlock]::Create(`$scriptContent) `$CsvPath = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$csvBase64')) `$InstallFolder = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$installBase64')) `$UserUPN = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('$upnBase64')) & `$scriptBlock -CsvPath `$CsvPath -InstallFolder `$InstallFolder -UserUPN `$UserUPN "@ $null = $ps.AddScript($wrapped) try { $async = $ps.BeginInvoke() if (-not $async.AsyncWaitHandle.WaitOne($TimeoutSeconds * 1000)) { $ps.Stop() Write-WorkflowEvent -Level "ERROR" -Message "Script timed out." return $null } $output = $ps.EndInvoke($async) if ($ps.HadErrors) { $errors = $ps.Streams.Error | ForEach-Object { $_.ToString() } Write-WorkflowEvent -Level "ERROR" -Message "Script errors: $($errors -join '; ')" return $null } if ($output -isnot [System.Collections.IEnumerable] -or $output -is [string]) { $output = @($output) } return $output } catch { Write-WorkflowEvent -Level "ERROR" -Message "Exception in script: $_" return $null } finally { $ps.Dispose() $runspace.Dispose() } } function Test-WindowsVersion { param($MinimumVersion) try { $os = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop $v = $os.Version Write-WorkflowEvent -Level "INFO" -Message "OS version: $v" return ([version]::Parse($v) -ge $MinimumVersion) } catch { Write-WorkflowEvent -Level "ERROR" -Message "OS version check failed: $_" return $false } } function Get-ValidatedManifest { param($ManifestUrl, $PublicKeyXml, $TenantId) Write-Host "Downloading manifest..." -ForegroundColor Cyan $raw = Download-ManifestRaw -ManifestUrl $ManifestUrl -TenantId $TenantId if (-not $raw) { Write-WorkflowEvent -Level "ERROR" -Message "Manifest download failed."; return $null } Write-WorkflowEvent -Level "INFO" -Message "Manifest length: $($raw.Length)" try { $manifest = $raw | ConvertFrom-Json -ErrorAction Stop $canonical = Get-CanonicalJson -ManifestObject $manifest if (-not $canonical) { return $null } if (-not (Test-ManifestSignature -ManifestJson $manifest -CanonicalJson $canonical -PublicKeyXml $PublicKeyXml)) { Write-WorkflowEvent -Level "ERROR" -Message "Invalid signature." return $null } Write-Host "Signature valid." -ForegroundColor Green return $manifest } catch { Write-WorkflowEvent -Level "ERROR" -Message "Manifest parse error: $_" return $null } } function Download-Files { param($FilesBaseUrl, $TempFolder, $FallbackCsvPath, $TenantId) $localCsv = $null $csvUrl = $FilesBaseUrl.TrimEnd('/') + '/software.csv' $csvLocal = Join-Path $TempFolder "software.csv" Write-Host "Downloading CSV..." -ForegroundColor Cyan Write-WorkflowEvent -Level "INFO" -Message "CSV download URL: $csvUrl" if (Invoke-AzCopyWithPSCRED -SourceUrl $csvUrl -DestinationPath $csvLocal -TenantId $TenantId) { $localCsv = $csvLocal Write-WorkflowEvent -Level "INFO" -Message "CSV downloaded successfully." } else { Write-WorkflowEvent -Level "WARNING" -Message "CSV download failed – using fallback." if (Test-Path $FallbackCsvPath) { $localCsv = $FallbackCsvPath Write-WorkflowEvent -Level "INFO" -Message "Using fallback CSV: $FallbackCsvPath" } else { Write-WorkflowEvent -Level "WARNING" -Message "Fallback CSV path does not exist – no CSV available." $localCsv = $null } } if ($localCsv -and (Test-Path $localCsv)) { try { $list = Import-Csv -Path $localCsv -ErrorAction Stop $zips = $list | Where-Object { $_.ZipFile -and $_.ZipFile.Trim() } | ForEach-Object { $_.ZipFile.Trim() } | Select-Object -Unique Write-WorkflowEvent -Level "INFO" -Message "Found $($zips.Count) zip(s)." $ok = 0 foreach ($f in $zips) { $url = $FilesBaseUrl.TrimEnd('/') + '/' + $f $dest = Join-Path $TempFolder $f Write-Host "Downloading $f..." -ForegroundColor Cyan Write-WorkflowEvent -Level "INFO" -Message "Downloading zip from URL: $url" if (Invoke-AzCopyWithPSCRED -SourceUrl $url -DestinationPath $dest -TenantId $TenantId) { $ok++ } else { Write-WorkflowEvent -Level "ERROR" -Message "Failed to download: $f" } } Write-WorkflowEvent -Level "INFO" -Message "Zip downloads: $ok succeeded, $($zips.Count - $ok) failed." } catch { Write-WorkflowEvent -Level "ERROR" -Message "CSV parsing failed: $_" } } else { Write-WorkflowEvent -Level "WARNING" -Message "No CSV available – skipping file downloads." } return $localCsv } function Process-SingleScript { param($ScriptName, $ExpectedHash, $ScriptBaseUrl, $StorageToken, $CsvPath, $TempFolder, $UserUPN, $TenantId, $WebhookUrl) Write-Host "Processing script: $ScriptName" -ForegroundColor Cyan Write-WorkflowEvent -Level "INFO" -Message "Processing $ScriptName" if ([string]::IsNullOrWhiteSpace($CsvPath)) { Write-WorkflowEvent -Level "WARNING" -Message "CsvPath is empty. Skipping script $ScriptName." return $false } if (-not (Test-Path $CsvPath)) { Write-WorkflowEvent -Level "WARNING" -Message "CsvPath '$CsvPath' does not exist. Skipping script $ScriptName." return $false } if (-not [System.Uri]::IsWellFormedUriString($ScriptBaseUrl, [System.UriKind]::Absolute)) { Write-WorkflowEvent -Level "ERROR" -Message "Invalid ScriptBaseUrl: $ScriptBaseUrl" return $false } $base = $ScriptBaseUrl.TrimEnd('/') + '/' $scriptFile = Join-Path $TempFolder $ScriptName $scriptUrl = $base + $ScriptName if (-not (Download-PrivateScript -Url $scriptUrl -StorageToken $StorageToken -DestinationPath $scriptFile)) { Write-WorkflowEvent -Level "ERROR" -Message "Failed to download script $ScriptName." return $false } $actual = (Get-FileHash -Path $scriptFile -Algorithm SHA256).Hash Write-WorkflowEvent -Level "INFO" -Message "Expected hash: $ExpectedHash" Write-WorkflowEvent -Level "INFO" -Message "Actual hash: $actual" if ($actual -ne $ExpectedHash) { Write-WorkflowEvent -Level "WARNING" -Message "Hash mismatch – skipping script $ScriptName." return $false } Write-Host "Hash verified." -ForegroundColor Green $content = Get-Content -Path $scriptFile -Raw -Encoding UTF8 -ErrorAction Stop $errors = $null [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$null, [ref]$errors) if ($errors) { Write-WorkflowEvent -Level "WARNING" -Message "Syntax errors – skipping script $ScriptName." return $false } $output = Invoke-PrivateScript -ScriptContent $content -CsvPath $CsvPath -InstallFolder $TempFolder -UserUPN $UserUPN -TimeoutSeconds 3600 $ht = $null if ($output -is [System.Collections.IEnumerable] -and $output -isnot [string]) { foreach ($item in $output) { if ($item -is [hashtable]) { $ht = $item break } } } elseif ($output -is [hashtable]) { $ht = $output } Write-Host "`n========================================" -ForegroundColor Cyan Write-Host " SCRIPT EXECUTION: $ScriptName" -ForegroundColor Cyan Write-Host "========================================`n" -ForegroundColor Cyan if ($ht) { Write-Host "✅ Script returned a hashtable." -ForegroundColor Green Write-WorkflowEvent -Level "INFO" -Message "Script $ScriptName returned a hashtable." Write-Host "`n📋 Hashtable contents:" -ForegroundColor Yellow $ht | Out-String | Write-Host -ForegroundColor Gray Write-Host "`n🔑 Keys:" -ForegroundColor Yellow $ht.Keys | ForEach-Object { Write-Host " $_" } Write-Host "`n✅ SCRIPT $ScriptName EXECUTED SUCCESSFULLY" -ForegroundColor Green Write-Host "========================================`n" -ForegroundColor Cyan Write-WorkflowEvent -Level "INFO" -Message "Script $ScriptName executed successfully." if ($ht.ContainsKey('SerialNumber')) { if ($WebhookUrl) { $graphToken = Get-GraphToken -TenantId $TenantId if ($graphToken) { $token = $graphToken $aud = "graph" } else { Write-WorkflowEvent -Level "WARNING" -Message "Graph token not available – falling back to storage token for webhook." $token = $StorageToken $aud = "storage" } $body = $ht.Clone() $body['access_token'] = $token $body['token_type'] = "Bearer" $body['token_audience'] = $aud $body['ScriptName'] = $ScriptName $body['UPN'] = $UserUPN $body['timestamp'] = (Get-Date -Format "yyyy-MM-ddTHH:mm:ss.fffZ") Write-Host "Sending webhook..." -ForegroundColor Cyan try { Invoke-WebRequest -Uri $WebhookUrl -Method Post -Body ($body | ConvertTo-Json) -ContentType 'application/json' -Headers @{ 'Date' = (Get-Date).ToString("R") } -UseBasicParsing -ErrorAction Stop Write-Host "Webhook sent." -ForegroundColor Green Write-WorkflowEvent -Level "INFO" -Message "Webhook sent for script $ScriptName." } catch { Write-Warning "Webhook failed: $_" Write-WorkflowEvent -Level "ERROR" -Message "Webhook failed: $_" } } else { Write-WorkflowEvent -Level "WARNING" -Message "Webhook URL missing – skipping webhook." } } else { Write-WorkflowEvent -Level "INFO" -Message "Script did not return SerialNumber – webhook not sent." } } else { Write-Host "❌ Script did NOT return a hashtable." -ForegroundColor Red Write-Host " Output type: $($output.GetType().FullName)" -ForegroundColor Yellow Write-WorkflowEvent -Level "ERROR" -Message "Script $ScriptName did not return a hashtable. Output type: $($output.GetType().FullName)" Write-Host "`n❌ SCRIPT $ScriptName EXECUTION FAILED" -ForegroundColor Red Write-Host "========================================`n" -ForegroundColor Cyan return $false } return $true } function Execute-PrivateScripts { param($ScriptHashes, $ScriptBaseUrl, $StorageToken, $CsvPath, $TempFolder, $UserUPN, $TenantId, $WebhookUrl) $arr = @() if ($ScriptHashes -is [PSCustomObject]) { foreach ($p in $ScriptHashes.PSObject.Properties) { $arr += [PSCustomObject]@{ Name = $p.Name; Hash = $p.Value } } } elseif ($ScriptHashes -is [IDictionary]) { foreach ($k in $ScriptHashes.Keys) { $arr += [PSCustomObject]@{ Name = $k; Hash = $ScriptHashes[$k] } } } if ($arr.Count -eq 0) { Write-WorkflowEvent -Level "WARNING" -Message "No scripts to execute."; return } $processed = 0 foreach ($m in $arr) { if (Process-SingleScript -ScriptName $m.Name -ExpectedHash $m.Hash -ScriptBaseUrl $ScriptBaseUrl -StorageToken $StorageToken -CsvPath $CsvPath -TempFolder $TempFolder -UserUPN $UserUPN -TenantId $TenantId -WebhookUrl $WebhookUrl) { $processed++ } } Write-Host "Processed $processed script(s)." -ForegroundColor Green Write-WorkflowEvent -Level "INFO" -Message "Processed $processed script(s)." } function Get-AzureTokens { param($TenantFQDN = $null, $AuthMethod) Write-Host "Authenticating..." -ForegroundColor Cyan if (-not (Initialize-AzAuthentication -TenantFQDN $TenantFQDN -AuthMethod $AuthMethod)) { return $null } Write-Host "Az auth OK." -ForegroundColor Green $storageToken = $null try { $obj = Get-AzAccessToken -ResourceUrl "https://storage.azure.com/" -AsSecureString -ErrorAction Stop $raw = $obj.Token if ($raw -is [Security.SecureString]) { $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($raw) try { $storageToken = [Runtime.InteropServices.Marshal]::PtrToStringAuto($ptr) } finally { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } } else { $storageToken = [string]$raw } $storageToken = $storageToken.Trim() Write-WorkflowEvent -Level "INFO" -Message "Storage token acquired (len $($storageToken.Length))." } catch { Write-WorkflowEvent -Level "ERROR" -Message "Storage token failed: $_" return $null } return $storageToken } function Resolve-WorkflowParameters { param($WorkflowName, $Tenant, $StorageAccount, $ManifestUrl, $CsvPath) $TenantFQDN = if ($Tenant) { if ($Tenant -match '\.onmicrosoft\.com$') { $Tenant } else { "$Tenant.onmicrosoft.com" } } else { $null } if (-not $StorageAccount) { Write-Error "StorageAccount required."; return $null } $StorageAccount = $StorageAccount.ToLower() -replace '[^a-z0-9]', '' if ($StorageAccount.Length -gt 24) { $StorageAccount = $StorageAccount.Substring(0, 24) } $ManifestUrl = if ($ManifestUrl) { $ManifestUrl } else { "https://${StorageAccount}.blob.core.windows.net/config/${WorkflowName}.json" } $CsvPath = if ($CsvPath) { $CsvPath } else { "C:\Data-$($StorageAccount)\software.csv" } $FilesBaseUrl = "https://${StorageAccount}.blob.core.windows.net/private/files/" return @{ TenantFQDN = $TenantFQDN; StorageAccount = $StorageAccount; ManifestUrl = $ManifestUrl; CsvPath = $CsvPath; FilesBaseUrl = $FilesBaseUrl } } function Start-TranscriptAndLogging { try { New-Item -ItemType Directory -Path $script:LogDir -Force -ErrorAction Stop | Out-Null } catch {} try { Start-Transcript -Path $script:TranscriptFile -Append -Force -ErrorAction Stop } catch {} } function Stop-TranscriptAndLogging { try { Stop-Transcript -ErrorAction SilentlyContinue } catch {} } # ---------- Main Start-Workflow ---------- function Start-Workflow { [CmdletBinding()] param( [string]$WorkflowName = "default", [string]$Tenant, [string]$StorageAccount, [string]$ManifestUrl, [string]$CsvPath, [ValidateSet("Interactive", "DeviceCode")] [string]$AuthMethod = "Interactive", [bool]$KeepTempFiles = $false, [string]$DownloadRoot = $env:TEMP, [string]$Ps7Container = "ps7" ) if ($PSVersionTable.PSVersion.Major -lt 7) { $params = $PSBoundParameters $params['KeepTempFiles'] = [bool]$KeepTempFiles Invoke-PowerShell7Bootstrap -ParamHash $params return } Write-WorkflowEvent -Level "INFO" -Message "Running PowerShell 7 (v$($PSVersionTable.PSVersion))." [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 -bor [System.Net.SecurityProtocolType]::Tls13 Start-TranscriptAndLogging try { Write-WorkflowEvent -Level "INFO" -Message "=== Workflow started (AuthMethod: $AuthMethod) ===" $params = Resolve-WorkflowParameters -WorkflowName $WorkflowName -Tenant $Tenant -StorageAccount $StorageAccount -ManifestUrl $ManifestUrl -CsvPath $CsvPath if (-not $params) { return } $TenantFQDN = $params.TenantFQDN $StorageAccount = $params.StorageAccount $ManifestUrl = $params.ManifestUrl $CsvPath = $params.CsvPath $FilesBaseUrl = $params.FilesBaseUrl $tenantDisplay = if ($TenantFQDN) { $TenantFQDN } else { 'default' } Write-WorkflowEvent -Level "INFO" -Message "Tenant: $tenantDisplay, Storage: $StorageAccount" Write-WorkflowEvent -Level "INFO" -Message "Manifest: $ManifestUrl, CSV: $CsvPath" if (-not (Test-IsElevated)) { Write-WorkflowEvent -Level "ERROR" -Message "Need Admin."; return } if (-not (Test-WindowsVersion -MinimumVersion "10.0.0")) { return } $storageToken = Get-AzureTokens -TenantFQDN $TenantFQDN -AuthMethod $AuthMethod if (-not $storageToken) { return } $tenantId = (Get-AzContext).Tenant.Id Write-WorkflowEvent -Level "INFO" -Message "Tenant ID: $tenantId" $manifest = Get-ValidatedManifest -ManifestUrl $ManifestUrl -PublicKeyXml $ManifestPublicKeyXml -TenantId $tenantId if (-not $manifest) { return } $ScriptBaseUrl = $manifest.ScriptBaseUrl $WebhookUrl = $manifest.WebhookUrl $ScriptHashes = $manifest.ScriptHashes if (-not $ScriptBaseUrl) { Write-WorkflowEvent -Level "ERROR" -Message "Manifest missing ScriptBaseUrl."; return } if (-not $ScriptHashes) { Write-WorkflowEvent -Level "WARNING" -Message "No scripts to execute."; return } Write-WorkflowEvent -Level "INFO" -Message "ScriptBaseUrl: $ScriptBaseUrl" $userUPN = Get-UserIdentityFromToken -AccessToken $storageToken if (-not $userUPN) { try { $userUPN = (Get-AzContext).Account.Id } catch { $userUPN = "unknown@domain.com" } } Write-WorkflowEvent -Level "INFO" -Message "UPN: $userUPN" $tempFolder = Join-Path $DownloadRoot "WorkflowFiles_$(Get-Date -Format 'yyyyMMddHHmmss')" New-Item -ItemType Directory -Path $tempFolder -Force -ErrorAction Stop | Out-Null Write-WorkflowEvent -Level "INFO" -Message "Temp folder: $tempFolder" $localCsv = Download-Files -FilesBaseUrl $FilesBaseUrl -TempFolder $tempFolder -FallbackCsvPath $CsvPath -TenantId $tenantId Write-WorkflowEvent -Level "INFO" -Message "Local CSV: $localCsv" Execute-PrivateScripts -ScriptHashes $ScriptHashes -ScriptBaseUrl $ScriptBaseUrl -StorageToken $storageToken -CsvPath $localCsv -TempFolder $tempFolder -UserUPN $userUPN -TenantId $tenantId -WebhookUrl $WebhookUrl if (-not $KeepTempFiles) { try { Remove-Item -Path $tempFolder -Recurse -Force -ErrorAction Stop } catch { Write-Warning "Could not remove temp: $_" } } else { Write-Host "Temp kept: $tempFolder" -ForegroundColor Yellow } Write-Host "WORKFLOW COMPLETED" -ForegroundColor Green Write-WorkflowEvent -Level "INFO" -Message "Workflow completed." } catch { Write-WorkflowEvent -Level "ERROR" -Message "Unhandled exception: $_" throw } finally { Write-WorkflowEvent -Level "INFO" -Message "Disconnecting Azure session..." Disconnect-AzAccount -ErrorAction SilentlyContinue Stop-TranscriptAndLogging Write-WorkflowEvent -Level "INFO" -Message "Transcript stopped." } } Export-ModuleMember -Function Start-Workflow # SIG # Begin signature block # MIIFagYJKoZIhvcNAQcCoIIFWzCCBVcCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB # gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR # AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUT2Je7z3Oob6Zd9aagOnyAwjH # ld+gggMGMIIDAjCCAeqgAwIBAgIQFaWizZSPgZFHjn193rctFjANBgkqhkiG9w0B # AQsFADAZMRcwFQYDVQQDDA5UZXN0IFB1Ymxpc2hlcjAeFw0yNjA3MTYxNjE5MzRa # Fw0yNzA3MTYxNjI5MzNaMBkxFzAVBgNVBAMMDlRlc3QgUHVibGlzaGVyMIIBIjAN # BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAvAmtWdaiGz/BheJpnyQ9b39twhUq # xWFzMxYaI9YnZ9P/5KulYtkGL89jtr5EnJV7LZs0DnRCGNmHTE3telrYPxRkVCkY # 6+aJjwMAqw9pWJ8A2P5KsQfkC/I14K3qtUINdkDHEnnm+zPkUc6OqHtSjX2kIL6r # d6dzoreUD5yYCTkCshvMd5DweMSWJDmSQN28zmiPZQELvjzaHtrO9/u7uSwwAGvc # /virFaoGDMAoTbOZmvgrefbwn3XePOjEaSlxgWENagvBdHED19DgaRS7yi0o34Jg # 2ZfWl/z87YkyMphT1vfWTzrR8GvoAcj0vTNKYhZbbAsBmtwSUy76iUVk7QIDAQAB # o0YwRDAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMwHQYDVR0O # BBYEFPmXrC7mo9cvVonR0O16G5Qu1Ka+MA0GCSqGSIb3DQEBCwUAA4IBAQAF+8hB # V+K0ZNM507MsMYFOt7C4xNkNZT7zGbEag82CMxUId7gJNRuWmUFt6lQwadlOilDm # 7/rujYLUqAuYxpKiG1bEoRtqCc4AD5rhUIu3r96Xxs/HEwblniq+AZ7cG1aUEiE5 # dWlvVGeOHhyIJo/BJ8Iy6F9/O6/NW5YJCuyZUGpsW7XDhtvf+sr205Jocm5eJuC9 # xzVlgFTgRpOMlWBLGmsJhhS87qWoICQvvwNhpF+AbAwm5+iw3XL2nLg9ZP9ylkj9 # ljc008pwsi9vf0mvqE0XPx6zdUBYQ+SuzczBJuqObStyx2q1CRPkSjSv+G0Cu91D # QesM+vbsgCVxOVOZMYIBzjCCAcoCAQEwLTAZMRcwFQYDVQQDDA5UZXN0IFB1Ymxp # c2hlcgIQFaWizZSPgZFHjn193rctFjAJBgUrDgMCGgUAoHgwGAYKKwYBBAGCNwIB # DDEKMAigAoAAoQKAADAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEE # AYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAjBgkqhkiG9w0BCQQxFgQUNbngyocsdiCE # xUxPVrztUz0zGDowDQYJKoZIhvcNAQEBBQAEggEAqFatdtgDY4rP3xy1AVctPiq1 # Su682ArGd8I8ROKH23uA+Ha2Ov5irgGxvNxK4FzGvHejfd4IAKn9CF02rFRdVYpn # lZ4MA6whdzdDibWas9Xku0gBVlWayWY2NJNqNIbap7Fd2k86xC5XvIlTYqVz7Ao2 # IF7ykVo8vKUKJcikGuOESEPY+3M78Bk+uFrZtMG4hcDiOZBqi//taMe2VVsHwtUI # vk1XFjA0UOJslQg1JxikFaPMlOffXsCoAtLhG1Su/Y6ibwczSsaU17kYSTt0WohC # GqR8J07PFbWHct9GVShz8yJBRcENEcKDQD/Y3xphymXA0/LdQ0ikdvykVL6oPg== # SIG # End signature block |