#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 # --- 0. Discard stale state from a prior interrupted build ----------------- # An aborted run can leave a DISM image mounted (locking install.wim/boot.wim) # or registry hives loaded, which breaks the Stage 2 extract clean-up with # "the process cannot access the file ... because it is being used by another # process". Discard anything of ours before (re)creating the work dirs. Match # by 'silvermetal' so orphans from any prior WorkDir are cleaned too. Write-Stage 'Stage 0: discard stale SilverMetal image mounts / hives from prior runs' Get-WindowsImage -Mounted -ErrorAction SilentlyContinue | Where-Object { $_.ImagePath -match 'silvermetal' -or $_.MountPath -match 'silvermetal' } | ForEach-Object { Write-Host " discarding stale mount: $($_.MountPath)" Dismount-WindowsImage -Path $_.MountPath -Discard -ErrorAction SilentlyContinue | Out-Null } foreach ($h in 'SM_BRAND_SW','SM_BRAND_DU','SM_OFFLINE','SM_BOOT') { & reg unload "HKLM\$h" 2>$null | Out-Null } if (Test-Path $WorkDir) { Remove-Item $WorkDir -Recurse -Force -ErrorAction SilentlyContinue } $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 { # Inject the answer file into the WinPE image at a fixed X: path, and launch # legacy setup with an EXPLICIT /unattend -- the implicit media search is # unreliable when setup is launched via the CmdLine override (legacy Setup # otherwise still shows the language page). Copy-Item (Join-Path $PSScriptRoot 'autounattend\autounattend.xml') (Join-Path $bootmnt 'autounattend.xml') -Force # Add WinPE .NET + PowerShell so the collector (WinForms) can run in WinPE. $adk = 'C:\Program Files (x86)\Windows Kits\10\Assessment and Deployment Kit\Windows Preinstallation Environment\amd64\WinPE_OCs' foreach ($oc in @('WinPE-WMI.cab','WinPE-NetFx.cab','WinPE-Scripting.cab','WinPE-PowerShell.cab')) { $cab = Join-Path $adk $oc if (Test-Path $cab) { Add-WindowsPackage -Path $bootmnt -PackagePath $cab | Out-Null; Write-Host " added WinPE OC: $oc" } else { Write-Warning " WinPE OC missing on runner: $cab (collector needs the ADK WinPE add-on; boot.wim assertions will fail without it)" } } # Stage the collector + winpeshl so WinPE launches it instead of Setup. $smDir = Join-Path $bootmnt 'sm'; $null = New-Item -ItemType Directory -Force $smDir Copy-Item (Join-Path $PSScriptRoot '..\collector\*') $smDir -Recurse -Force Copy-Item (Join-Path $smDir 'winpeshl.ini') (Join-Path $bootmnt 'Windows\System32\winpeshl.ini') -Force Write-Host " staged collector to boot.wim \sm\ + winpeshl.ini" # Setup\CmdLine is the WinPE setup-image shell launch and is AUTHORITATIVE over # winpeshl.ini -- point it at the SilverMetal collector so the pre-config UI runs # FIRST. The collector then launches the LEGACY setup.exe itself (X:\sources\setup.exe, # preserving the legacy-Setup bypass) with its generated answer file, or falls back to # the default autounattend.xml on cancel/error. (Pointing Setup\CmdLine straight at # setup.exe bypassed the collector entirely -- it won over winpeshl.ini.) $cmdline = "cmd /c X:\sm\Start-Collector.cmd" $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 + explicit unattend)" } finally { [gc]::Collect(); Start-Sleep -Seconds 2 & reg unload 'HKLM\SM_BOOT' | Out-Null } } finally { Dismount-WindowsImage -Path $bootmnt -Save | Out-Null } } # --- 3b. Publish Welcome app (runs before WIM mount; no mount needed) -------- function Invoke-PublishWelcome { if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') { Write-Host ' SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome app publish.' -ForegroundColor Yellow return } Write-Stage 'Stage 3b: publish SilverOS Welcome app (win-x64 self-contained)' $proj = Join-Path $WindowsDir 'welcome\src\SilverOS.Welcome.App' $out = Join-Path $WorkDir 'welcome-publish' # Force a CLEAN compile. The CI runner reuses build artifacts across runs, and dotnet's # incremental build has shipped a STALE SilverOS.Welcome.Core.dll (old code despite fixed # source) -- so wipe every bin/obj under welcome/ before publishing (a clean tree forces a # full recompile; note `dotnet publish` does NOT accept --no-incremental). Get-ChildItem (Join-Path $WindowsDir 'welcome') -Recurse -Directory -EA SilentlyContinue | Where-Object { $_.Name -in 'bin', 'obj' } | Remove-Item -Recurse -Force -EA SilentlyContinue & dotnet publish $proj -c Release -f net9.0-windows10.0.19041.0 -r win-x64 --self-contained true -o $out if ($LASTEXITCODE -ne 0) { throw 'Welcome app dotnet publish failed' } Write-Host " Published to: $out" } # --- 3c. Copy Welcome payload into mounted WIM (called inside Invoke-ServiceWim) --- function Copy-WelcomePayload { if ($env:SILVERMETAL_WELCOME_ENABLED -eq '0') { Write-Host ' SILVERMETAL_WELCOME_ENABLED=0 -- skipping Welcome payload copy.' -ForegroundColor Yellow return } Write-Stage 'Stage 3c: stage SilverOS Welcome app + flavours into mounted image' $out = Join-Path $WorkDir 'welcome-publish' if (-not (Test-Path $out)) { throw "Welcome publish output not found at '$out' -- did Invoke-PublishWelcome succeed?" } # Destination: C:\Program Files\SilverOS\Welcome (+ flavours subdir) $dest = Join-Path $mount 'Program Files\SilverOS\Welcome' $destFlavours = Join-Path $dest 'flavours' $null = New-Item -ItemType Directory -Force $dest, $destFlavours # Copy published app (SilverOS.Welcome.App.exe + all runtime deps) Copy-Item "$out\*" $dest -Recurse -Force # Copy flavour definitions (*.json) $flavoursDir = Join-Path $WindowsDir 'flavours' $flavourFiles = Get-ChildItem $flavoursDir -Filter '*.json' -EA SilentlyContinue if ($flavourFiles) { Copy-Item $flavourFiles.FullName $destFlavours -Force Write-Host " Copied $($flavourFiles.Count) flavour(s) to $destFlavours" } else { Write-Warning " No *.json flavour files found in $flavoursDir -- image will ship with no flavours." } # Stage the app catalog + configure/bootstrap scripts next to the Welcome app # (mirrors the flavours copy above): catalog.json, configure\*.ps1, bootstrap-winget.ps1. $appsDest = Join-Path $dest 'apps' $null = New-Item -ItemType Directory -Force $appsDest $appsDir = Join-Path $WindowsDir 'apps' if (Test-Path $appsDir) { Copy-Item (Join-Path $appsDir '*') $appsDest -Recurse -Force Write-Host " Copied app catalog + scripts to $appsDest" } else { Write-Warning " No apps dir found at $appsDir -- image will ship with no app catalog." } # Stage the fixed-version WebView2 runtime, if vendored, next to the app. # Cold-start + air-gap: a fixed-version runtime is just files (no installer # step at first boot) and removes the dependency on whether IoT Enterprise LTSC # ships WebView2 at all. Operator populates windows\welcome\runtime\webview2\ # with an EXTRACTED "Microsoft Edge WebView2 Fixed Version" distribution (the # folder that contains msedgewebview2.exe) -- handled like the drivers dir: # absent is allowed (VM/dev test), in which case the app falls back to Evergreen. $wv2Src = Join-Path $WindowsDir 'welcome\runtime\webview2' if (Test-Path (Join-Path $wv2Src 'msedgewebview2.exe')) { $wv2Dest = Join-Path $dest 'webview2' $null = New-Item -ItemType Directory -Force $wv2Dest Copy-Item (Join-Path $wv2Src '*') $wv2Dest -Recurse -Force Write-Host " Staged fixed-version WebView2 runtime to $wv2Dest" } else { Write-Warning " No fixed-version WebView2 runtime at $wv2Src (expected msedgewebview2.exe) -- image will rely on the Evergreen runtime being present at first boot. See windows\welcome\runtime\webview2\README.md." } # --- Guard: verify the payload actually landed in the mounted image ------- $stagedExe = Join-Path $dest 'SilverOS.Welcome.App.exe' if (-not (Test-Path $stagedExe)) { throw "Welcome bake failed: SilverOS.Welcome.App.exe missing from image (expected at '$stagedExe'). Check that dotnet publish produced the exe and Copy-Item succeeded." } $stagedFlavours = Get-ChildItem $destFlavours -Filter '*.json' -EA SilentlyContinue if (-not $stagedFlavours) { throw "Welcome bake failed: no flavour manifests staged in '$destFlavours'. Add *.json files under windows/flavours/ or the installed wizard will have no flavour choices." } $stagedCatalog = Join-Path $appsDest 'catalog.json' if (-not (Test-Path $stagedCatalog)) { throw "Welcome bake failed: app catalog.json missing from image (expected at '$stagedCatalog'). Add windows/apps/catalog.json or the wizard's Apps step will be empty." } Write-Host " Welcome payload staged at $dest" } # --- 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)) { # -ForceUnsigned: skip the offline-inject signature check (the virtio NetKVM # driver is WHQL-signed and loads fine at boot; the offline check can still # reject it on the build host). Non-fatal: a driver issue must not brick the # whole image build -- warn and continue without it. Write-Host ' adding drivers' try { Add-WindowsDriver -Path $mount -Driver $drv -Recurse -ForceUnsigned -ErrorAction Stop | Out-Null } catch { Write-Warning " driver inject failed (continuing without it): $($_.Exception.Message)" } } else { Write-Host ' no .inf drivers staged (ok for VM test)' } # Kiosk features (Shell Launcher v2 + Keyboard Filter) — IoT Enterprise LTSC. if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') { Write-Host ' enabling Shell Launcher + Keyboard Filter features' Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-EmbeddedShellLauncher -All -NoRestart | Out-Null Enable-WindowsOptionalFeature -Path $mount -FeatureName Client-KeyboardFilter -All -NoRestart | Out-Null } # 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 $PSScriptRoot 'oem\Configure-Kiosk.ps1') $scripts -Force Copy-Item (Join-Path $WindowsDir 'hardening\*') (Join-Path $scripts 'hardening') -Recurse -Force # Stage the branding module so SetupComplete.cmd can re-apply branding ONLINE # (Windows resets the offline personalization bake during OOBE). $brandDest = Join-Path $scripts 'branding' $null = New-Item -ItemType Directory -Force $brandDest Copy-Item (Join-Path $WindowsDir 'branding\*') $brandDest -Recurse -Force # Stage Welcome app + flavours while the WIM is still mounted. Copy-WelcomePayload # Bake the four branding layers into the offline hives (must be inside the mount). Write-Stage 'Stage 3d: bake SilverMetal branding (OEM/lockscreen/desktop/bitlocker)' & (Join-Path $WindowsDir 'branding\Apply-Branding.ps1') -Mode Offline -MountPath $mount if ($LASTEXITCODE -ne 0) { throw 'branding apply failed' } # Bake offline UAC auto-approve policy so the Welcome wizard (launched via # Shell Launcher v2 (Configure-Kiosk.ps1) as the sm-bootstrap shell, which # elevates the app) silently elevates during the ephemeral sm-bootstrap # session without a UAC prompt. # UAC stays enabled (EnableLUA=1); the wizard's hardening re-tightens the # policy for the daily user. Only applies when Welcome is enabled. if ($env:SILVERMETAL_WELCOME_ENABLED -ne '0') { Write-Stage 'Stage 3e: bake offline UAC auto-approve policy (silent elevation for sm-bootstrap)' $hive = Join-Path $mount 'Windows\System32\config\SOFTWARE' & reg load HKLM\SM_OFFLINE "$hive" | Out-Null if ($LASTEXITCODE -ne 0) { throw 'reg load SOFTWARE hive failed' } try { & reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v ConsentPromptBehaviorAdmin /t REG_DWORD /d 0 /f | Out-Null & reg add 'HKLM\SM_OFFLINE\Microsoft\Windows\CurrentVersion\Policies\System' /v PromptOnSecureDesktop /t REG_DWORD /d 0 /f | Out-Null Write-Host ' ConsentPromptBehaviorAdmin=0, PromptOnSecureDesktop=0 written to offline SOFTWARE hive.' } finally { [gc]::Collect(); Start-Sleep -Milliseconds 500 & reg unload HKLM\SM_OFFLINE | Out-Null } } } 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 -------------------------------------------------------------- # NOTE: branding edits the OFFLINE hives, so it must run while the WIM is still # mounted. We therefore call it from inside Invoke-ServiceWim (see Step 2), and # this stage just asserts the staged result for the SBOM/log. function Invoke-Brand { Write-Stage 'Stage 5: branding (applied during WIM servicing)' Write-Host ' branding layers baked via branding\Apply-Branding.ps1 -Mode Offline' } # --- 6. Repack ------------------------------------------------------------- function Invoke-Repack { Write-Stage 'Stage 6: repack UEFI-bootable ISO (oscdimg)' $etfs = Join-Path $isoRoot 'boot\etfsboot.com' # Use the STANDARD prompt boot image (efisys.bin). The no-prompt variant + a # media-first boot order causes a reinstall LOOP: every post-copy reboot # re-boots the media before the disk install completes, so it never finishes. # The "press any key to boot from CD/USB" prompt lets reboots fall through to # the installed disk. (Initial media boot = one keypress or a firmware boot-menu # selection — expected for a USB-installed SKU.) $efi = Join-Path $isoRoot 'efi\microsoft\boot\efisys.bin' if (-not (Test-Path $efi)) { throw "missing UEFI boot image: $efi" } # 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-PublishWelcome # publish Welcome app before mount (no WIM needed) Invoke-ServiceWim # mounts WIM, stages hardening + Welcome payload, dismounts Invoke-InjectUnattend Invoke-Brand Invoke-Repack Invoke-Attest Write-Host "`nBuild complete: $OutputIso" -ForegroundColor Green