Private/ScanBridgeModuleInstall.ps1
|
# ScanBridge runtime provisioning for the task account: PowerShell 7 and the Gallery modules the # worker loads, installed for all users in both module folders (Windows PowerShell and # PowerShell 7), with older all-users NSP.M365.Graph copies removed. Plans first; DRY RUN stops # after the plan. $script:NSPScanBridgeRuntimeModules=@( [pscustomobject]@{Name='NSP.ScanBridge';MinimumVersion=[version]'0.1.0';ReplaceOlder=$false} [pscustomobject]@{Name='NSP.M365.Graph';MinimumVersion=[version]'0.1.2';ReplaceOlder=$true} [pscustomobject]@{Name='Microsoft.Graph.Authentication';MinimumVersion=[version]'2.0.0';ReplaceOlder=$false} ) function Get-NSPAllUsersModuleRoot { # Windows PowerShell's all-users folder, then PowerShell 7's. if($script:NSPAllUsersModuleRootOverride) { return @($script:NSPAllUsersModuleRootOverride) } @((Join-Path $env:ProgramFiles 'WindowsPowerShell\Modules'),(Join-Path $env:ProgramFiles 'PowerShell\Modules')) } function Get-NSPAllUsersModuleVersion { # Installed copies of one module under one root: versioned folders, or a flat (unversioned) copy. param([Parameter(Mandatory)][string]$Root,[Parameter(Mandatory)][string]$Name) $base=Join-Path $Root $Name if(-not (Test-Path -LiteralPath $base -PathType Container)) { return } $flat=Join-Path $base "$Name.psd1" if(Test-Path -LiteralPath $flat -PathType Leaf) { [pscustomobject]@{Name=$Name;Version=[version](Import-PowerShellDataFile -LiteralPath $flat).ModuleVersion;Path=$base;Layout='Flat'} } foreach($dir in @(Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue)) { $manifest=Join-Path $dir.FullName "$Name.psd1" if(Test-Path -LiteralPath $manifest -PathType Leaf) { [pscustomobject]@{Name=$Name;Version=[version](Import-PowerShellDataFile -LiteralPath $manifest).ModuleVersion;Path=$dir.FullName;Layout='Versioned'} } } } function Find-NSPPowerShell7 { $candidates=@((Join-Path $env:ProgramFiles 'PowerShell\7\pwsh.exe')) + @(Get-Command pwsh.exe -All -CommandType Application -ErrorAction SilentlyContinue | ForEach-Object Source) foreach($path in $candidates) { if($path -and (Test-Path -LiteralPath $path -PathType Leaf)) { return [pscustomobject]@{Path=(Get-Item -LiteralPath $path).FullName;Version=(Get-Item -LiteralPath $path).VersionInfo.ProductVersion} } } } function Find-NSPGalleryModule { param([Parameter(Mandatory)][string]$Name) Find-Module -Name $Name -Repository PSGallery -ErrorAction SilentlyContinue | Select-Object -First 1 } function Save-NSPGalleryModule { param([Parameter(Mandatory)][string]$Name,[Parameter(Mandatory)][version]$Version,[Parameter(Mandatory)][string]$Path) Save-Module -Name $Name -RequiredVersion $Version -Path $Path -Repository PSGallery -Force -ErrorAction Stop } function Save-NSPPowerShell7Installer { # Latest stable MSI for this processor from the PowerShell GitHub releases, kept only when its # Authenticode signature is valid and Microsoft Corporation's. param([Parameter(Mandatory)][string]$Destination) $architecture=if($env:PROCESSOR_ARCHITEW6432){$env:PROCESSOR_ARCHITEW6432}else{$env:PROCESSOR_ARCHITECTURE} $suffix=if($architecture -eq 'ARM64'){'win-arm64.msi'}else{'win-x64.msi'} [Net.ServicePointManager]::SecurityProtocol=[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $release=Invoke-RestMethod -Uri 'https://api.github.com/repos/PowerShell/PowerShell/releases/latest' -UseBasicParsing -ErrorAction Stop $asset=@($release.assets | Where-Object { $_.name -like "PowerShell-*-$suffix" })[0] if(-not $asset -or $asset.browser_download_url -notmatch '^https://github\.com/PowerShell/PowerShell/releases/download/') { throw "The latest PowerShell release has no $suffix from the PowerShell repository." } New-Item -ItemType Directory -Path $Destination -Force | Out-Null $msi=Join-Path $Destination $asset.name Invoke-WebRequest -Uri $asset.browser_download_url -OutFile $msi -UseBasicParsing -ErrorAction Stop $signature=Get-AuthenticodeSignature -LiteralPath $msi $signer=if($signature.SignerCertificate){$signature.SignerCertificate.Subject}else{''} if([string]$signature.Status -ne 'Valid' -or $signer -notmatch '(^|,\s*)O=Microsoft Corporation(,|$)') { Remove-Item -LiteralPath $msi -Force -ErrorAction SilentlyContinue throw "Downloaded $($asset.name) is not validly signed by Microsoft (status $($signature.Status)). It was deleted." } [pscustomobject]@{Path=$msi;Version=[string]$release.tag_name;Signer=$signer} } function Install-NSPPowerShell7Msi { param([Parameter(Mandatory)][string]$MsiPath) $arguments=@('/i',"`"$MsiPath`"",'/qn','/norestart','ADD_PATH=1','REGISTER_MANIFEST=1','USE_MU=1','ENABLE_MU=1') $process=Start-Process -FilePath 'msiexec.exe' -ArgumentList $arguments -Wait -PassThru -WindowStyle Hidden if($process.ExitCode -notin 0,3010) { throw "PowerShell 7 installer failed with exit code $($process.ExitCode)." } $process.ExitCode } function Test-NSPScanBridgeModuleResolution { # What a PowerShell 7 process without user-profile module folders would load: for each module, # the first folder on PSModulePath that has it (what Import-Module -Name picks) and the first # copy meeting the minimum version (what -MinimumVersion picks). Read-only; imports nothing. param([Parameter(Mandatory)][string]$PowerShellPath,[string[]]$ModuleRoot) $names=($script:NSPScanBridgeRuntimeModules | ForEach-Object { "$($_.Name)=$($_.MinimumVersion)" }) -join ';' $probe=@' $profileRoot=[Environment]::GetFolderPath('UserProfile') $roots=if($args[1]) { @($args[1] -split ';') } else { @($env:PSModulePath -split ';' | Where-Object { $_ -and -not $_.StartsWith($profileRoot,[StringComparison]::OrdinalIgnoreCase) }) } foreach($pair in ($args[0] -split ';')) { $name,$minimum=$pair -split '=' $first=$null;$satisfying=$null foreach($root in $roots) { $base=Join-Path $root $name if(-not (Test-Path -LiteralPath $base -PathType Container)) { continue } $manifests=@(Join-Path $base "$name.psd1") + @(Get-ChildItem -LiteralPath $base -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName "$name.psd1" }) $found=@(foreach($manifest in $manifests) { if(Test-Path -LiteralPath $manifest -PathType Leaf) { try { [pscustomobject]@{Version=[version](Import-PowerShellDataFile -LiteralPath $manifest).ModuleVersion;ModuleBase=(Split-Path -Parent $manifest)} } catch { } } }) | Sort-Object Version -Descending if(-not @($found).Count) { continue } $found=@($found) if(-not $first) { $first=$found[0] } if(-not $satisfying) { $satisfying=@($found | Where-Object { $_.Version -ge [version]$minimum })[0] } } '{0}|{1}|{2}|{3}|{4}' -f $name,$minimum,$(if($first){$first.Version}),$(if($first){$first.ModuleBase}),$(if($satisfying){$satisfying.Version}) } '@ $rootArgument=(@($ModuleRoot) -join ';') -replace "'","''" $encoded=[Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes("& {`n$probe`n} '$names' '$rootArgument'")) foreach($line in @(& $PowerShellPath -NoLogo -NoProfile -NonInteractive -EncodedCommand $encoded)) { $name,$minimum,$first,$base,$satisfying=([string]$line) -split '\|' [pscustomobject]@{Module=$name;Minimum=$minimum;FirstFound=$first;FirstFoundPath=$base;MeetsMinimum=$satisfying; Status=$(if(-not $first){'Missing'}elseif($first -and [version]$first -ge [version]$minimum){'OK'}elseif($satisfying){'OlderFirst'}else{'TooOld'})} } } function Invoke-NSPScanBridgeModuleInstall { <# Plans, then on confirm applies: PowerShell 7 when missing (Microsoft-signed MSI from the PowerShell GitHub releases), each runtime module from the Gallery into both all-users folders when no copy there meets the minimum, and removal of older all-users NSP.M365.Graph copies. Then checks what PowerShell 7 would load. #> [CmdletBinding()] param() $dryRun=[bool](Get-NSPDryRun) if(-not $dryRun -and -not (Test-NSPScanBridgeElevated)) { Write-NSPConsoleLine 'Installing for all users needs an elevated session. Restart PowerShell as administrator.' -Role Error; return } $roots=@(Get-NSPAllUsersModuleRoot) $pwsh=Find-NSPPowerShell7 $steps=[System.Collections.Generic.List[object]]::new() if($pwsh) { Write-NSPConsoleLine ("PowerShell 7 {0} at {1}." -f $pwsh.Version,$pwsh.Path) -Role Muted } else { $steps.Add([pscustomobject]@{Action='InstallPowerShell7';Text='Install PowerShell 7 (latest stable MSI from Microsoft''s PowerShell GitHub releases; kept only when Microsoft-signed)'}) } foreach($module in $script:NSPScanBridgeRuntimeModules) { $gallery=Find-NSPGalleryModule -Name $module.Name if(-not $gallery -or [version]$gallery.Version -lt $module.MinimumVersion) { Write-NSPConsoleLine ("{0} {1} or later is not on the PowerShell Gallery yet; it cannot be installed." -f $module.Name,$module.MinimumVersion) -Role Warning } foreach($root in $roots) { $installed=@(Get-NSPAllUsersModuleVersion -Root $root -Name $module.Name) if(-not @($installed | Where-Object { $_.Version -ge $module.MinimumVersion }).Count -and $gallery -and [version]$gallery.Version -ge $module.MinimumVersion) { $steps.Add([pscustomobject]@{Action='Save';Name=$module.Name;Version=[version]$gallery.Version;Root=$root;Text="Install $($module.Name) $($gallery.Version) into $root"}) } if($module.ReplaceOlder) { foreach($old in @($installed | Where-Object { $_.Version -lt $module.MinimumVersion })) { $steps.Add([pscustomobject]@{Action='Remove';Name=$module.Name;Version=$old.Version;Path=$old.Path;Text="Remove older $($module.Name) $($old.Version) at $($old.Path)"}) } } } } if(-not $steps.Count) { Write-NSPConsoleLine 'PowerShell 7 and every runtime module are already installed for all users.' -Role Success } else { Write-NSPConsoleLine 'Install plan:' foreach($step in $steps) { Write-NSPConsoleLine " - $($step.Text)" } if($dryRun) { Write-NSPConsoleLine 'DRY RUN: nothing installed or removed.' -Role Warning } elseif(Read-NSPConfirm -Prompt 'Apply this install plan now?') { foreach($step in $steps) { try { switch($step.Action) { 'InstallPowerShell7' { $installer=Save-NSPPowerShell7Installer -Destination (Join-Path $env:ProgramData 'NSP\Installers') Write-NSPConsoleLine ("Downloaded PowerShell {0}, signed by {1}." -f $installer.Version,$installer.Signer) -Role Muted $code=Install-NSPPowerShell7Msi -MsiPath $installer.Path Write-NSPConsoleLine $(if($code -eq 3010){'PowerShell 7 installed; Windows asks for a restart to finish.'}else{'PowerShell 7 installed.'}) -Role Success $pwsh=Find-NSPPowerShell7 } 'Save' { Save-NSPGalleryModule -Name $step.Name -Version $step.Version -Path $step.Root; Write-NSPConsoleLine "Installed $($step.Name) $($step.Version) into $($step.Root)." -Role Success } 'Remove' { Remove-Item -LiteralPath $step.Path -Recurse -Force -ErrorAction Stop; Write-NSPConsoleLine "Removed $($step.Name) $($step.Version)." -Role Success } } } catch { Write-NSPConsoleLine "$($step.Text) failed: $($_.Exception.Message)" -Role Error } } } } if(-not $pwsh) { Write-NSPConsoleLine 'PowerShell 7 is not installed, so its module resolution was not checked.' -Role Warning; return } $resolution=@(Test-NSPScanBridgeModuleResolution -PowerShellPath $pwsh.Path) foreach($row in $resolution) { $role=switch($row.Status) { 'OK' {'Success'} 'Missing' {'Error'} default {'Warning'} } $text=switch($row.Status) { 'OK' { "{0}: PowerShell 7 loads {1} from {2}." -f $row.Module,$row.FirstFound,$row.FirstFoundPath } 'Missing' { "{0}: not found by PowerShell 7 outside user profiles." -f $row.Module } 'OlderFirst' { "{0}: {1} is found first ({2}); {3} meets the minimum only when requested by version." -f $row.Module,$row.FirstFound,$row.FirstFoundPath,$row.MeetsMinimum } default { "{0}: only {1} is found; {2} or later is needed." -f $row.Module,$row.FirstFound,$row.Minimum } } Write-NSPConsoleLine $text -Role $role } Write-NSPConsoleLine 'This check runs as you without user-profile folders; the task account''s first run is the real proof.' -Role Muted $resolution } function Resolve-NSPScanBridgeInstalledRunner { # The runner of the highest all-users NSP.ScanBridge (PowerShell 7's folder first), or $null. $roots=@(Get-NSPAllUsersModuleRoot) [array]::Reverse($roots) foreach($root in $roots) { $best=@(Get-NSPAllUsersModuleVersion -Root $root -Name 'NSP.ScanBridge' | Sort-Object Version -Descending)[0] if($best) { $runner=Join-Path $best.Path 'scripts\Run-ScanBridge.ps1' if(Test-Path -LiteralPath $runner -PathType Leaf) { return [pscustomobject]@{Runner=$runner;Version=$best.Version;Root=$root} } } } } |