Files
pxe-server/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1
cproudlock 8907a86c42 Collector: decode StartupApproved 0x04 and 0x05
The post-fix capture from 579C144 reported three entries as unknown(0x04) -
SecurityHealth, RtkAudUService, WavesSvc. The decoder only knew 02/06 enabled and
03/07 disabled. 04 is also enabled and 05 also disabled; without them the report
says 'unknown' for entries that are perfectly ordinary.
2026-08-06 17:16:57 -04:00

410 lines
19 KiB
PowerShell

<#
.SYNOPSIS
Collect everything needed to diagnose an imaged bay, into one zip.
.DESCRIPTION
Read-only. Changes nothing on the machine.
Written because every imaging investigation so far has meant walking to a bay
and hand-copying files, and each time we discovered another thing we wished
we had grabbed at the same moment. This takes the lot in one pass.
Run it on a freshly imaged bay BEFORE lockdown.
This captures KNOWN-CURRENT, not known-good. A bay straight off the line has
applications on it that should not be there - preinstall.json entries without
a PCTypes filter install everywhere, so a Display bay picks up Adobe,
OpenText and Defect Tracker. The point of collecting is to have an exact
record of what imaging really produces, so the unnecessary items can be
identified and filtered out.
Before lockdown specifically, because afterwards you cannot tell whether
something is absent because lockdown removed it or because imaging never
installed it.
.PARAMETER Destination
Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect
centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs
(only reachable while the bay is still on the imaging LAN).
.PARAMETER Label
Optional tag folded into the filename, e.g. -Label pre-lockdown.
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown
.EXAMPLE
.\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs
#>
[CmdletBinding()]
param(
[string]$Destination = 'C:\Logs',
[string]$Label = ''
)
$ErrorActionPreference = 'Continue'
$ProgressPreference = 'SilentlyContinue'
$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' }
$stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" }
$work = Join-Path $env:TEMP "imgdiag-$name"
New-Item -ItemType Directory -Path $work -Force | Out-Null
function Section { param([string]$File, [scriptblock]$Body)
$p = Join-Path $work $File
try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 }
catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 }
Write-Host (" {0}" -f $File)
}
function CopyTree { param([string]$Src, [string]$Dst)
# /XF *.ppkg and /MAX are not optional. run-enrollment harvests
# C:\ProgramData\Microsoft\Provisioning into C:\Logs\PPKG, so the 8 GB
# provisioning package exists TWICE under the trees we copy. Without these
# the collection is 16 GB and Compress-Archive dies with "stream was too
# long" - it cannot exceed 2 GB.
if (Test-Path $Src) {
$d = Join-Path $work $Dst
New-Item -ItemType Directory -Path $d -Force | Out-Null
robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS /XF *.ppkg *.wim *.iso /MAX:104857600 | Out-Null
Write-Host (" {0}\ <- {1}" -f $Dst, $Src)
}
}
Write-Host ""
Write-Host "Collecting imaging diagnostics for $serial ..."
Write-Host ""
# --- 1. Identity ---------------------------------------------------------
# ActiveComputerName is the name in use; ComputerName is the PENDING one. They
# differ when a rename is queued for the next reboot - which is exactly how the
# H<serial> -> F<serial> transition works, so capturing both tells you whether
# the package's rename landed or is still waiting.
Section 'identity.txt' {
'== computer names =='
'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName
'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName
'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname'
''
'== hardware =='
Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List
Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List
Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List
''
'== uptime / install =='
'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate
}
# --- 2. Enrollment state -------------------------------------------------
# dsregcmd is the authority on whether the Entra join actually happened.
# criticalChecks.json is the package's own self-assessment and the two can
# disagree - "Entra ID Joined: false" right after imaging is normal because the
# bay has not reached the production network yet.
Section 'enrollment.txt' {
'== dsregcmd /status =='
& dsregcmd /status
''
'== package self-checks =='
foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json',
'C:\Logs\BPRT\Orchestrator\TokenMatch.json') {
if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw }
}
''
'== staged selections =='
foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt',
'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt',
'C:\Enrollment\fetch-source.txt') {
if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','<redacted>') }
}
}
# --- 3. Installed applications ------------------------------------------
# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying
# it triggers an MSI reconfigure of every installed product, which is slow and
# can actually change the machine.
Section 'installed-apps.txt' {
$paths = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*'
)
Get-ItemProperty $paths -EA SilentlyContinue |
Where-Object { $_.DisplayName } |
Sort-Object DisplayName |
Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation |
Format-Table -AutoSize
}
# --- 4. Everything that starts by itself --------------------------------
# THE section for "why did this app launch". Installers plant their own startup
# entries, so an app being installed IS an app being started - there is no
# separate switch. Capturing all four surfaces shows which app planted what.
# Note both registry views: a 32-bit installer's Run key lands under
# Wow6432Node and 64-bit tooling never sees it.
Section 'autostart.txt' {
'== HKLM Run / RunOnce (native + WOW64) =='
foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run',
'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== HKCU Run / RunOnce (current user) =='
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
''
'== Startup folders =='
foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp",
"$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize }
}
''
'== per-user Startup folders (all profiles) =='
Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object {
$d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup'
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize }
}
''
'== enabled/disabled state (StartupApproved) =='
# Disabling a startup item via Task Manager or Settings does NOT remove the
# Run key or the Startup shortcut - it writes a flag here. So an entry can
# appear above and still be switched off. First byte 02/06 = enabled,
# 03/07 = disabled. Capturing this is what tells "imaging installed it and
# it runs" apart from "imaging installed it and somebody turned it off",
# which matters because the fix is not to install it at all.
foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run32',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\StartupFolder') {
if (Test-Path $k) {
"--- $k ---"
$props = Get-Item $k
foreach ($n in $props.Property) {
$v = (Get-ItemProperty $k -Name $n).$n
$state = if ($v -is [byte[]] -and $v.Length -ge 1) {
switch ($v[0]) { 2 {'ENABLED'} 4 {'ENABLED'} 6 {'ENABLED'} 3 {'disabled'} 5 {'disabled'} 7 {'disabled'} default {"unknown(0x{0:X2})" -f $v[0]} }
} else { 'unknown' }
"{0,-10} {1}" -f $state, $n
}
}
}
''
'== scheduled tasks (non-Microsoft) =='
Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
Select-Object TaskPath, TaskName, State,
@{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }},
@{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} |
Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap
''
'== services set to auto-start (non-Microsoft paths) =='
Get-CimInstance Win32_Service -EA SilentlyContinue |
Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } |
Select-Object Name, DisplayName, State, StartMode, PathName |
Sort-Object Name | Format-Table -AutoSize -Wrap
}
# --- 5. Kiosk / display specifics ---------------------------------------
Section 'display-kiosk.txt' {
'== kiosk shortcuts anywhere in Startup =='
Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue |
ForEach-Object {
$sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName)
"{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments
}
''
'== Edge policies =='
foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge',
'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') {
if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List }
}
}
# --- 6. GE-Enforce -------------------------------------------------------
Section 'ge-enforce.txt' {
'== registry config =='
foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC',
'HKLM:\SOFTWARE\GE\ShopDB') {
# values only - do not dump anything that looks like a secret
if (Test-Path $k) {
"--- $k ---"
Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object {
$v = (Get-ItemProperty $k -Name $_).$_
if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = <redacted, length $($v.ToString().Length)>" }
else { "$_ = $v" }
}
}
}
''
'== enforce client / DSC on disk =='
foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? -----------
# Config on disk proves nothing - the client can be present, configured, and
# never once succeed. This answers the actual question: is there a client, is it
# scheduled, has it run, and can this bay reach the endpoint right now.
#
# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing
# and TLS all work and the service answered. Only a timeout or DNS failure means
# genuinely unreachable. No token is sent, so nothing here can enrol or change
# anything.
Section 'geenforce-api.txt' {
'== is the client installed? =='
$clientPaths = @(
'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB',
'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1',
'C:\Enrollment\ShopdbEnforceClient.psm1'
)
$found = $false
foreach ($p in $clientPaths) {
if (Test-Path $p) { $found = $true; "FOUND: $p"
Get-ChildItem $p -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' }
''
'== scheduled tasks that would run it =='
$tasks = Get-ScheduledTask -EA SilentlyContinue |
Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' }
if ($tasks) {
foreach ($t in $tasks) {
$i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue
"{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f `
$t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime
($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" })
}
} else { 'NONE - nothing scheduled to call the API.' }
''
'== configured base URL =='
$base = $null
foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') {
if (Test-Path $k) {
$p = Get-ItemProperty $k
foreach ($n in $p.PSObject.Properties.Name) {
# Match the NAME tightly and validate the VALUE looks like a URL.
# A loose "base" match picked up baseVersion=2.0.2 and the probe
# then tried to fetch "2.0.2/api/docs".
if ($n -match '(?i)^(baseurl|serverurl|shopdburl|endpoint|url)$') {
"$k\$n = $($p.$n)"
if (-not $base -and "$($p.$n)" -match '^https?://') { $base = $p.$n }
}
}
}
}
if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' }
if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' }
''
"== can this bay reach it right now? (base: $base) =="
$targets = @(
@{ Name = 'API docs'; Url = "$base/api/docs" },
@{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" }
)
foreach ($t in $targets) {
try {
$sw = [Diagnostics.Stopwatch]::StartNew()
$r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop
$sw.Stop()
"{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds
} catch {
$code = try { $_.Exception.Response.StatusCode.value__ } catch { $null }
if ($code) {
"{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code
} else {
"{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message
' NOTE: ShopDB is only reachable once the bay has joined the AESFMA wifi SSID.'
' On the imaging LAN or plain wired, unreachable here is EXPECTED, not a fault.'
}
}
}
''
'== name resolution / route =='
try {
$h = ([uri]$base).Host
"host: $h"
Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize
Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue |
Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List
} catch { "DNS/route check failed: $_" }
''
'== client logs, if any =='
foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') {
if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize }
}
}
# --- 7. Drivers, disk, network ------------------------------------------
Section 'drivers-disk-network.txt' {
'== third-party drivers =='
& pnputil /enum-drivers
''
'== disk layout =='
Get-Disk -EA SilentlyContinue | Format-Table -AutoSize
Get-Partition -EA SilentlyContinue | Format-Table -AutoSize
Get-Volume -EA SilentlyContinue | Format-Table -AutoSize
''
'== network =='
Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize
Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize
Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize
}
# --- 8. Provisioning session state --------------------------------------
Section 'provisioning-sessions.txt' {
'== sessions =='
$k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions'
if (Test-Path $k) {
Get-ChildItem $k | ForEach-Object {
$p = Get-ItemProperty $_.PSPath
"{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount
}
} else { '(no provisioning sessions key)' }
}
# --- 9. Log trees --------------------------------------------------------
CopyTree 'C:\Logs' 'Logs'
CopyTree 'C:\Windows\Panther' 'Panther'
CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData'
foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') {
if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue }
}
# Enrollment dir listing only - it holds an 8 GB package we do not want.
Section 'enrollment-dir-listing.txt' {
if (Test-Path 'C:\Enrollment') {
Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue |
Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize
}
}
# --- 10. Provisioning event log -----------------------------------------
try {
$evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx'
& wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null
if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' }
} catch { }
# --- zip -----------------------------------------------------------------
New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null
$zip = Join-Path $Destination "imgdiag-$name.zip"
try {
Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop
Remove-Item $work -Recurse -Force -EA SilentlyContinue
Write-Host ""
Write-Host "Wrote $zip"
Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB))
} catch {
Write-Host ""
Write-Host "Could not zip to $Destination : $_"
Write-Host "Raw collection left at: $work"
}
Write-Host ""