#Requires -Version 5.1 <# .SYNOPSIS SilverMetal Enhanced - Windows : custom packed ISO build pipeline. .DESCRIPTION Turns an official Windows 11 IoT Enterprise LTSC ISO (an INPUT, never redistributed) plus the SilverMetal config layer into a hardened, branded, UEFI-bootable ISO with an SBOM + SHA-256 attestation. Design: ../iso-builder.md Controls: ../hardening-spec.md Build host requirement: Windows x64 + Windows ADK (oscdimg) + DISM (built in). Must run ELEVATED (DISM image mount requires admin). .NOTES M2 implementation. Validated by running on the silverlabs-runner-win Gitea runner (this repo cannot be built on the ARM64 dev box). Offline policy baking is intentionally minimal here -- the §A-H hardening runs at first boot via SetupComplete.cmd; this pipeline injects drivers, debloat, the hardening payload, the answer file, then repacks + attests. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $SourceIso, [string] $Manifest = "$PSScriptRoot\inputs.manifest.json", [string] $WorkDir = "$env:TEMP\silvermetal-build", [string] $OutputIso = "$PSScriptRoot\out\SilverMetal-Enhanced-Windows.iso", [string] $WindowsDir = "$PSScriptRoot\..", # repo windows/ root [switch] $SkipInputVerify ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' function Write-Stage { param([string]$Msg) Write-Host "==> $Msg" -ForegroundColor Cyan } function Resolve-Tool { param([string]$Name,[string[]]$Globs) $c = Get-Command $Name -EA SilentlyContinue if ($c) { return $c.Source } foreach ($g in $Globs) { $p = Get-ChildItem $g -EA SilentlyContinue | Select-Object -First 1; if ($p) { return $p.FullName } } throw "$Name not found. Install the Windows ADK Deployment Tools." } if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent() ).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'build.ps1 must run elevated (DISM image servicing requires admin).' } $oscdimg = Resolve-Tool 'oscdimg.exe' @( 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Deployment Tools\*\Oscdimg\oscdimg.exe') $m = Get-Content $Manifest -Raw | ConvertFrom-Json $isoRoot = Join-Path $WorkDir 'iso' # writable copy of ISO contents $mount = Join-Path $WorkDir 'mount' # install.wim mount point $bootmnt = Join-Path $WorkDir 'bootmnt' # boot.wim mount point $null = New-Item -ItemType Directory -Force -Path $WorkDir,$mount,$bootmnt,(Split-Path $OutputIso) # --- 1. Verify input ------------------------------------------------------- function Invoke-VerifyInput { Write-Stage 'Stage 1: verify input ISO against pinned manifest' $expected = $m.baseImage.isoSha256 if ($SkipInputVerify -or $expected -like 'TODO*') { $actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash Write-Warning "Input hash not pinned. Observed SHA-256: $actual (record in inputs.manifest.json)." return } $actual = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash if ($actual -ne $expected) { throw "Source ISO SHA-256 mismatch.`n expected: $expected`n actual: $actual" } Write-Host ' ISO hash verified.' } # --- 2. Extract ------------------------------------------------------------ function Invoke-Extract { Write-Stage 'Stage 2: extract ISO to writable work dir' if (Test-Path $isoRoot) { Remove-Item $isoRoot -Recurse -Force } $null = New-Item -ItemType Directory -Force $isoRoot $img = Mount-DiskImage -ImagePath $SourceIso -PassThru try { $drive = ($img | Get-Volume).DriveLetter + ':' Write-Host " mounted at $drive ; copying..." Copy-Item "$drive\*" $isoRoot -Recurse -Force } finally { Dismount-DiskImage -ImagePath $SourceIso | Out-Null } attrib -r "$isoRoot\*" /s /d 2>$null # clear read-only carried from the ISO } # --- 2b. Force legacy Setup (bypass the 24H2 "ConX" front-end) ------------- function Invoke-ForceLegacySetup { Write-Stage 'Stage 2b: force legacy Setup (patch sources\boot.wim)' # Win11 24H2/25H2 ship a redesigned WinPE Setup front-end (setup.exe -> # SetupHost.exe -> SetupPrep.exe) that IGNORES the windowsPE pass of # autounattend.xml (manual language/keyboard/region prompts). No answer-file # element toggles it; the verified fix is to point WinPE's shell at the # LEGACY installer via HKLM\SYSTEM\Setup\CmdLine in boot.wim index 2 (the # Setup image). The legacy engine consumes all four passes -> hands-off. $bootwim = Join-Path $isoRoot 'sources\boot.wim' if (-not (Test-Path $bootwim)) { throw "boot.wim not found: $bootwim" } Mount-WindowsImage -ImagePath $bootwim -Index 2 -Path $bootmnt | Out-Null try { $cmdline = 'X:\sources\setup.exe' if (-not (Test-Path (Join-Path $bootmnt 'sources\setup.exe')) -and (Test-Path (Join-Path $bootmnt 'setup.exe'))) { $cmdline = 'X:\setup.exe' } $hive = Join-Path $bootmnt 'Windows\System32\config\SYSTEM' & reg load 'HKLM\SM_BOOT' $hive | Out-Null try { & reg add 'HKLM\SM_BOOT\Setup' /v CmdLine /t REG_SZ /d $cmdline /f | Out-Null Write-Host " WinPE Setup\CmdLine = $cmdline (legacy Setup forced)" } finally { [gc]::Collect(); Start-Sleep -Seconds 2 & reg unload 'HKLM\SM_BOOT' | Out-Null } } finally { Dismount-WindowsImage -Path $bootmnt -Save | Out-Null } } # --- 3. Service the WIM offline (DISM) ------------------------------------- function Invoke-ServiceWim { Write-Stage 'Stage 3: offline-service install.wim' $sources = Join-Path $isoRoot 'sources' $wim = Join-Path $sources 'install.wim' $esd = Join-Path $sources 'install.esd' if (-not (Test-Path $wim) -and (Test-Path $esd)) { Write-Host ' converting install.esd -> install.wim' $idx = (Get-WindowsImage -ImagePath $esd | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex if (-not $idx) { $idx = 1 } Export-WindowsImage -SourceImagePath $esd -SourceIndex $idx -DestinationImagePath $wim -CompressionType Max Remove-Item $esd -Force } $idx = (Get-WindowsImage -ImagePath $wim | Where-Object ImageName -match 'IoT Enterprise LTSC' | Select-Object -First 1).ImageIndex if (-not $idx) { $idx = ($m.baseImage.wimImageIndex); if (-not $idx) { $idx = 1 } } Write-Host " servicing image index $idx" Mount-WindowsImage -ImagePath $wim -Index $idx -Path $mount | Out-Null try { # Drivers (GPD Pocket 4 pack) -- skipped silently if dir empty (e.g. VM test). $drv = Join-Path $WindowsDir 'drivers' if ((Get-ChildItem $drv -Recurse -Filter *.inf -EA SilentlyContinue)) { Write-Host ' adding drivers'; Add-WindowsDriver -Path $mount -Driver $drv -Recurse | Out-Null } else { Write-Host ' no .inf drivers staged (ok for VM test)' } # Debloat: remove provisioned appx listed in debloat/appx-remove.txt (best-effort). $list = Join-Path $WindowsDir 'debloat\appx-remove.txt' if (Test-Path $list) { $prov = Get-AppxProvisionedPackage -Path $mount Get-Content $list | Where-Object { $_ -and $_ -notmatch '^\s*#' } | ForEach-Object { $name = $_.Trim() $prov | Where-Object DisplayName -like "$name*" | ForEach-Object { try { Remove-AppxProvisionedPackage -Path $mount -PackageName $_.PackageName | Out-Null; Write-Host " removed $($_.DisplayName)" } catch {} } } } # Stage the hardening payload: SetupComplete.cmd (native auto-run as SYSTEM) + modules. $scripts = Join-Path $mount 'Windows\Setup\Scripts' $null = New-Item -ItemType Directory -Force $scripts, (Join-Path $scripts 'hardening') Copy-Item (Join-Path $PSScriptRoot 'oem\SetupComplete.cmd') $scripts -Force Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force } finally { Dismount-WindowsImage -Path $mount -Save | Out-Null } } # --- 4. Inject answer file ------------------------------------------------- function Invoke-InjectUnattend { Write-Stage 'Stage 4: inject autounattend.xml (ISO root + \sources)' $src = Join-Path $PSScriptRoot 'autounattend\autounattend.xml' Copy-Item $src (Join-Path $isoRoot 'autounattend.xml') -Force # Also place in \sources (the implicit search location the windowsPE pass uses). Copy-Item $src (Join-Path $isoRoot 'sources\autounattend.xml') -Force } # --- 5. Brand -------------------------------------------------------------- function Invoke-Brand { Write-Stage 'Stage 5: branding'; Write-Warning ' deferred to M4.' } # --- 6. Repack ------------------------------------------------------------- function Invoke-Repack { Write-Stage 'Stage 6: repack UEFI-bootable ISO (oscdimg)' $etfs = Join-Path $isoRoot 'boot\etfsboot.com' # Prefer the no-prompt UEFI boot image so the ISO boots hands-off (no "press # any key"); fall back to the prompt variant if absent. $efi = Join-Path $isoRoot 'efi\microsoft\boot\efisys_noprompt.bin' if (-not (Test-Path $efi)) { $efi = Join-Path $isoRoot 'efi\microsoft\boot\efisys.bin' } if (-not (Test-Path $efi)) { throw "missing UEFI boot image under efi\microsoft\boot\" } # Work paths have no spaces (SYSTEM TEMP / runner temp), so omit oscdimg's # inner quotes around the boot images -- otherwise PowerShell mangles the # native -bootdata arg into doubled quotes (oscdimg Error 123). $bootdata = "2#p0,e,b$etfs#pEF,e,b$efi" if (Test-Path $OutputIso) { Remove-Item $OutputIso -Force } & $oscdimg -m -o -u2 -udfver102 -lSILVERMETAL "-bootdata:$bootdata" $isoRoot $OutputIso if ($LASTEXITCODE -ne 0) { throw "oscdimg failed ($LASTEXITCODE)" } } # --- 7. Attest ------------------------------------------------------------- function Invoke-Attest { Write-Stage 'Stage 7: SHA-256 + SBOM' $hash = (Get-FileHash -Algorithm SHA256 $OutputIso).Hash "$hash *$(Split-Path $OutputIso -Leaf)" | Set-Content "$OutputIso.sha256" $sbom = [ordered]@{ product = $m.product builtFrom = @{ sourceIsoSha256 = (Get-FileHash -Algorithm SHA256 $SourceIso).Hash; manifest = $m.baseImage } output = @{ iso = (Split-Path $OutputIso -Leaf); sha256 = $hash } tooling = @{ oscdimg = $oscdimg; dism = (Get-Command dism.exe).Version.ToString() } note = 'Reproducibility: pinned inputs + SBOM + SHA, NOT bit-identical (iso-builder.md §5). Signing: TODO-M3.' } $sbom | ConvertTo-Json -Depth 6 | Set-Content "$OutputIso.sbom.json" Write-Host " ISO SHA-256: $hash" } # --- orchestrate ----------------------------------------------------------- Invoke-VerifyInput Invoke-Extract Invoke-ForceLegacySetup Invoke-ServiceWim Invoke-InjectUnattend Invoke-Brand Invoke-Repack Invoke-Attest Write-Host "`nBuild complete: $OutputIso" -ForegroundColor Green