diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd0ec9..bfe9703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # ShellKnight Changelog +## [v2026.09.24.001] - 2026-09-24 + +- **Check-ins restored: device identity no longer depends on the Assessment Engine (critical):** v2026.09.08.001 replaced the Defender catch that set `$defSigs = 'Unknown'` with fallbacks that set it only on success. Where every probe fails (`Get-MpComputerStatus` throws under SYSTEM, and `MSFT_MpComputerStatus` is missing or has no signature date because a third-party AV owns the box or Defender has been removed), reading the unset `$defSigs` in the `MachineInfo` literal threw under `Set-StrictMode -Version 2`. `Invoke-SafeBlock` logged it and moved on, `MachineInfo` stayed empty, and **`device_id` was sent as null**. Battlefield fell back to `host:`, which frozen enrollment does not recognise for a device enrolled by hardware UUID, so every POST was answered `200 {"status":"ignored"}` and nothing was stored. This is very likely the 2026-09-09 reporting drop that v2026.09.15.001 could not explain. `$defSigs`, and `$wuStr` (unset on an empty Windows Update history), now start as `'Unknown'`. Device identity (hardware UUID, then MachineGuid, then `host:`; same values as before) is now computed in its own block ahead of the engine and regardless of `AssessmentEngine_Enabled`. The result, `$Script:DeviceId`, starts at the hostname fallback so it is never null, and both `MachineInfo['Device ID']` and the payload `device_id` read it. An engine failure now costs machine details, never the check-in. +- **Report POSTed as UTF-8 (critical):** Windows PowerShell 5.1 encodes a string `-Body` as ISO-8859-1 when `-ContentType` carries no charset. A single character in U+0080..U+00FF (for example in an Event 7045 service name) became an invalid UTF-8 byte, and Battlefield rejected the whole report with `400 body is not valid JSON` until the event aged out of the 7-day window. Characters above U+00FF were best-fitted to ASCII, some of them to a quote or backslash that breaks the JSON outright. The body is now sent as UTF-8 bytes (`[System.Text.Encoding]::UTF8.GetBytes`) with `application/json; charset=utf-8`. +- **Regression test:** `tests/Test-DeviceIdentity.ps1` runs the Phase 2 code verbatim under StrictMode 2 with mocked Windows cmdlets. It covers Defender stopped, Defender removed, no signature date, empty update history, an engine that aborts, a disabled engine, and each identity fallback, and checks that the payload and POST are wired to `$Script:DeviceId` and UTF-8. CI picks it up with the other `tests/Test-*.ps1`. A scenario with WMI down checks that the id stays `host:` (as before) rather than switching to MachineGuid. +- **Network inventory stays disabled:** the fleet did not recover on v2026.09.15.001 because the cause was in .001, not the network block. Passive network inventory remains off until it has had its own real Windows run. + +## [v2026.09.08.001 - v2026.09.15.001] + +- Not recorded here; see the `.CHANGELOG` block at the top of `ShellKnight.ps1` for these releases. + ## [v2026.07.30.001] - 2026-07-30 - **Assessment Engine — restored (critical):** `Win32_BIOS.ReleaseDate` is already a `DateTime` under `Get-CimInstance`, but was still being parsed as the legacy `Get-WmiObject` CIM_DATETIME string via `.Split('.')`. Calling a string method on a `DateTime` raises MethodNotFound — a terminating error — and because the BIOS date is read four statements into the engine's `Invoke-SafeBlock`, **the entire Assessment Engine aborted on every run** and the failure was swallowed as an informational log line. Everything after that point never executed: OS name/build/EOL, architecture, RAM, PC age, uptime, last boot, domain/workgroup, logged-in user, disk figures, BitLocker status, Windows Update recency, and AV/EDR/Defender detection. Two consequences were reported to the dashboard as fact rather than as missing data: **every endpoint reported `antivirus: "NONE DETECTED"`** (the pre-block default, never overwritten by real detection), and **`device_id` was null**, so devices enrolled under the `host:` fallback instead of a stable hardware id (ADR 0006). BIOS date parsing is now a single non-throwing helper (`ConvertTo-BiosDate`) handling both the CIM `DateTime` and the legacy string, used by both call sites. The legacy path was itself broken — `.Split('.')[0]` left all 14 date/time digits, which `ParseExact` rejects against `yyyyMMdd` — so it now takes the leading 8 characters. diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index b098d42..facb452 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -2,7 +2,7 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - ShellKnight v2026.09.15.001 - Enterprise Endpoint Security & Remediation Tool + ShellKnight v2026.09.24.001 - Enterprise Endpoint Security & Remediation Tool .DESCRIPTION Automated endpoint security remediation, threat detection, hardening, and @@ -18,9 +18,9 @@ C. David Burgess - PTech LLC .VERSION - Version : v2026.09.15.001 - Released : 2026-09-15 - Prior : v2026.09.08.004 + Version : v2026.09.24.001 + Released : 2026-09-24 + Prior : v2026.09.15.001 .ENGINES Phase 1 - Intel Engine : Threat intelligence download and cache @@ -33,6 +33,36 @@ Phase 8 - Reporting Engine : Reporting, trending, and extended checks .CHANGELOG + v2026.09.24.001 - Check-ins restored; two silent field failures fixed. + (1) Devices "ignored" by Battlefield. v2026.09.08.001 dropped the + Defender catch that set $defSigs = 'Unknown', so where every probe + fails (Get-MpComputerStatus throws under SYSTEM, and the CIM class + is missing or has no signature date - third-party AV, Defender + removed) the unset $defSigs threw under StrictMode 2 inside the + MachineInfo literal. Invoke-SafeBlock logged it and moved on, + MachineInfo stayed empty and device_id went out null. Battlefield + fell back to host:, which frozen enrollment does not know + for a UUID-enrolled device, so every POST got 200 "ignored" and + nothing was stored. Very likely the 2026-09-09 reporting drop that + v2026.09.15.001 could not explain. $defSigs, and $wuStr (unset on + an empty Windows Update history), now start as 'Unknown'. Device + identity (UUID -> MachineGuid -> host:, same values) now + runs in its own block before, and regardless of, the engine; its + result $Script:DeviceId starts at the host: fallback, is + never null, and feeds both MachineInfo and the payload. An engine + failure now costs machine details, never the check-in. + (2) HTTP 400 "body is not valid JSON". Windows PowerShell 5.1 + encodes a string -Body as ISO-8859-1 when -ContentType has no + charset, so one character in U+0080..U+00FF (e.g. in an Event + 7045 service name) became an invalid UTF-8 byte and the whole + report was rejected until the event left the 7-day window. + Characters above U+00FF were best-fitted to ASCII, some to a quote + or backslash that breaks the JSON. The report is now POSTed as + UTF-8 bytes with 'application/json; charset=utf-8'. + The fleet did not recover on v2026.09.15.001 because the cause was + in .001, not the network block. Passive network inventory stays + disabled here all the same, until it has had its own real Windows + run. v2026.09.15.001 - ROLLBACK: passive network inventory disabled by default. Reporting hosts fell from 13-16/day to 6-7/day on 2026-09-09, the first full day after the .001-.004 releases, and stayed there for a @@ -401,7 +431,7 @@ param() # ============================================================================== -# SHELLKNIGHT v2026.09.15.001 CONFIGURATION +# SHELLKNIGHT v2026.09.24.001 CONFIGURATION # All settings are configured here. No external config files required. # Each engine can be independently enabled or disabled. # ============================================================================== @@ -594,7 +624,7 @@ try { # Runtime Config Object - single source of truth for all engines $Script:Config = [PSCustomObject]@{ - Version = 'v2026.09.15.001' + Version = 'v2026.09.24.001' # Intel Engine IntelEngine_Enabled = $SK_IntelEngine_Enabled IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates @@ -984,7 +1014,7 @@ $Script:UseNewPSFeatures = $Script:PSVer -ge 5 # Banner $bannerWidth = 78 -$version = 'ShellKnight v2026.09.15.001' +$version = 'ShellKnight v2026.09.24.001' $hostname = $env:COMPUTERNAME $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $psver = "PS $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" @@ -1192,6 +1222,33 @@ $Script:AvDetectionRan = $false $inactiveAccounts = (New-Object 'System.Collections.Generic.List[object]') $Script:MinPasswordLen = 0 +# Stable device identity - independent of hostname/site so Battlefield +# can track a machine across renames and site moves. Prefer the hardware +# UUID (survives OS reinstall); fall back to MachineGuid, then hostname. +# Computed here, outside and ahead of the Assessment Engine, so an engine abort +# can no longer send a null device_id (v2026.09.24.001). Seeded with the +# hostname fallback so it is never $null; assigned via $Script: because the +# block runs in a child scope, where a bare assignment would be lost. +$Script:DeviceId = "host:$($env:COMPUTERNAME)" +Invoke-SafeBlock -Label 'Device identity' -Block { + $deviceId = $null + $wmiUp = $true + try { + $hwUuid = (Get-CimInstance Win32_ComputerSystemProduct -ErrorAction Stop).UUID + if ($hwUuid -and $hwUuid -notmatch '^(0{8}-0{4}-0{4}-0{4}-0{12}|FFFFFFFF)' ) { $deviceId = $hwUuid.Trim() } + } catch { + # Inside the engine this code only ran once Win32_OperatingSystem had + # answered; with WMI down the report went out as host:. Keep + # that, so a WMI outage cannot re-enroll a UUID-known machine under + # its MachineGuid. + try { $null = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop } catch { $wmiUp = $false } + } + if (-not $deviceId -and $wmiUp) { + try { $deviceId = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid } catch { } + } + if ($deviceId) { $Script:DeviceId = $deviceId } +} + if ($Script:Config.AssessmentEngine_Enabled) { Invoke-SafeBlock -Label 'Assessment Engine' -Block { @@ -1323,7 +1380,11 @@ if ($Script:Config.AssessmentEngine_Enabled) { # back to the CIM class, then to the service + registry, so a module # failure can never be mistaken for "no protection". $defRtp stays $null # while genuinely unknown so it is distinguishable from a real DISABLED. + # $defSigs is seeded because only the success paths below set it: when + # every probe failed, reading it unset in the MachineInfo literal threw + # under StrictMode and aborted the whole engine (v2026.09.24.001). $defRtp = $null + $defSigs = 'Unknown' try { $mp = Get-MpComputerStatus -ErrorAction Stop $defRtp = [bool]($mp.AMServiceEnabled -and $mp.RealTimeProtectionEnabled) @@ -1359,8 +1420,10 @@ if ($Script:Config.AssessmentEngine_Enabled) { ($defenderRegistered -and $defStatus -ne 'DISABLED') $Script:AvDetectionRan = $true - # Windows Update last install + # Windows Update last install. $wuStr is seeded so an empty update + # history cannot leave it unset for the MachineInfo literal (StrictMode). $wuDate = $null + $wuStr = 'Unknown' try { $wu = New-Object -ComObject Microsoft.Update.Session -ErrorAction Stop $searcher = $wu.CreateUpdateSearcher() @@ -1380,18 +1443,7 @@ if ($Script:Config.AssessmentEngine_Enabled) { -ErrorAction SilentlyContinue | Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation, UninstallString) - # Stable device identity - independent of hostname/site so Battlefield - # can track a machine across renames and site moves. Prefer the hardware - # UUID (survives OS reinstall); fall back to MachineGuid, then hostname. - $deviceId = $null - try { - $hwUuid = (Get-CimInstance Win32_ComputerSystemProduct -ErrorAction Stop).UUID - if ($hwUuid -and $hwUuid -notmatch '^(0{8}-0{4}-0{4}-0{4}-0{12}|FFFFFFFF)' ) { $deviceId = $hwUuid.Trim() } - } catch { } - if (-not $deviceId) { - try { $deviceId = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid } catch { } - } - if (-not $deviceId) { $deviceId = "host:$($env:COMPUTERNAME)" } + # Device identity is computed before the engine ($Script:DeviceId). # Hardware type from chassis (replaces the Datto lazy-fetch; ADR 0008) $hwType = 'Unknown' @@ -1409,7 +1461,7 @@ if ($Script:Config.AssessmentEngine_Enabled) { # Build machine info $Script:MachineInfo = [ordered]@{ - 'Device ID' = $deviceId + 'Device ID' = $Script:DeviceId 'Hardware Type' = $hwType 'Hostname' = $env:COMPUTERNAME 'OS' = "$osName (Build $osBuild)" @@ -3276,7 +3328,7 @@ $freeAfterGB = if ($diskAfter) { [math]::Round($diskAfter.FreeSpace / 1GB, 1) } $sepLine = '=' * 80 Log-Info $sepLine -Log-Info " ShellKnight v2026.09.15.001 - Report" +Log-Info " ShellKnight v2026.09.24.001 - Report" Log-Info " Hostname : $($env:COMPUTERNAME)" Log-Info " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Log-Info " Runtime : $runtime seconds" @@ -3289,7 +3341,7 @@ Log-Info $sepLine $bannerWidth2 = 78 Write-Host '' Write-Host " $sepLine" -ForegroundColor Cyan -Write-Host " ShellKnight v2026.09.15.001 - Report" -ForegroundColor Cyan +Write-Host " ShellKnight v2026.09.24.001 - Report" -ForegroundColor Cyan Write-Host " Hostname : $($env:COMPUTERNAME)" -ForegroundColor White Write-Host " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor White Write-Host " Runtime : $runtime seconds" -ForegroundColor White @@ -3561,8 +3613,8 @@ $jsonStamp= Get-Date -Format 'yyyy-MM-dd_HHmm' $jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json" $jsonData = [ordered]@{ - version = 'v2026.09.15.001' - device_id = $Script:MachineInfo['Device ID'] + version = 'v2026.09.24.001' + device_id = $Script:DeviceId hardware_type = $Script:MachineInfo['Hardware Type'] site_name = $SK_SiteName hostname = $env:COMPUTERNAME @@ -3626,8 +3678,15 @@ if ($Script:Config.BattlefieldEnabled) { } else { try { $headers = @{ 'X-API-Key' = $Script:Config.BattlefieldApiKey } + # Send UTF-8 bytes, not the string. Windows PowerShell 5.1 encodes a + # string -Body as ISO-8859-1 when -ContentType has no charset, so a + # single U+0080..U+00FF character (e.g. in an Event 7045 service + # name) went out as an invalid UTF-8 byte and Battlefield rejected + # the whole report with 400 (v2026.09.24.001). A byte[] body is + # written to the request as-is. $resp = Invoke-RestMethod -Uri $Script:Config.BattlefieldURL -Method Post ` - -Body $jsonBody -ContentType 'application/json' ` + -Body ([System.Text.Encoding]::UTF8.GetBytes($jsonBody)) ` + -ContentType 'application/json; charset=utf-8' ` -Headers $headers -TimeoutSec 20 -ErrorAction Stop # The server may accept the run (returns run_id) or decline it (e.g. # frozen enrollment returns {status:'ignored',reason:...}). Under diff --git a/tests/Test-DeviceIdentity.ps1 b/tests/Test-DeviceIdentity.ps1 new file mode 100644 index 0000000..6799b05 --- /dev/null +++ b/tests/Test-DeviceIdentity.ps1 @@ -0,0 +1,232 @@ +<# +.SYNOPSIS + Regression test: a Run always reports a real device_id, and the Assessment + Engine survives Defender and Windows Update probes that come back empty. + +.DESCRIPTION + v2026.09.24.001 fixed two linked bugs. v2026.09.08.001 left $defSigs unset + whenever every Defender probe failed, so reading it in the MachineInfo + literal threw under Set-StrictMode -Version 2 and aborted the whole engine. + Device identity was computed inside that engine, so the report went out + with device_id null, Battlefield fell back to host:, and under frozen + enrollment a device enrolled by hardware UUID was silently "ignored". + + This runs the Phase 2 code taken verbatim from ShellKnight.ps1 (from the + MachineInfo reset through the end of the MachineInfo literal) under the + script's own StrictMode 2 / SilentlyContinue settings, with the Windows + cmdlets replaced by mocks, so it runs on the CI Linux runner. It does not + replace a real Windows run. + + ShellKnight.ps1 is a monolith that executes on load, so the code is + extracted textually rather than dot-sourced. +#> +Set-StrictMode -Version 2 +# The test's own logic stops on any error, so a broken assertion fails loudly +# instead of being skipped. Only the extracted ShellKnight code runs under the +# script's own 'SilentlyContinue' (see the scenario loop). +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'ShellKnight.ps1' +$source = Get-Content -LiteralPath $scriptPath -Raw + +function Get-Section { + param([string]$Pattern, [string]$What) + $m = [regex]::Match($source, $Pattern) + if (-not $m.Success) { throw "$What not found in ShellKnight.ps1 - did it get renamed or moved?" } + $m.Value +} + +$safeBlock = Get-Section '(?ms)^function Invoke-SafeBlock \{.*?^\}' 'Invoke-SafeBlock' +$biosDate = Get-Section '(?ms)^function ConvertTo-BiosDate \{.*?^\}' 'ConvertTo-BiosDate' +# Phase 2 init, the device identity block, and the engine up to the end of the +# MachineInfo literal. The engine's Invoke-SafeBlock and if are closed by hand. +$phase2 = Get-Section ('(?ms)^\$Script:MachineInfo = \[ordered\]@\{\}\s*$.*?' + + '^ \$Script:MachineInfo = \[ordered\]@\{.*?^ \}\s*$') 'Phase 2 through the MachineInfo literal' +$phase2 += "`n }`n}`n" + +# --- Mocks. Functions take precedence over cmdlets of the same name. --------- +$Script:Scenario = '' +$Script:Logged = New-Object 'System.Collections.Generic.List[string]' +function Log-Info { param([string]$m) $Script:Logged.Add($m) } +function Log-Warn { param([string]$m) } +$Script:Config = [pscustomobject]@{ AssessmentEngine_Enabled = $true } +$Script:Counters = @{ IntelSource = 'test' } +$Script:HWInfo = @{ IsServer = $false } +$Script:PSFullVer = '5.1.22621.5697' +$origComputerName = $env:COMPUTERNAME # process-wide: restored at the end +$env:COMPUTERNAME = 'SK-TEST-PC' + +$Script:Uuid = '4C4C4544-0042-5810-8052-B4C04F4B4C33' +$Script:MachineGuid = 'b1e2c3d4-0000-1111-2222-333344445555' + +function Get-CimInstance { + param($ClassName, $Namespace, $Filter, $ErrorAction) + $s = $Script:Scenario + switch ($ClassName) { + 'Win32_OperatingSystem' { + if ($s -in 'engine-aborts', 'wmi-down') { throw 'Invalid class (WMI repository damaged)' } + return [pscustomobject]@{ Caption = 'Microsoft Windows 11 Pro'; BuildNumber = '22621' + OSArchitecture = '64-bit'; LastBootUpTime = (Get-Date).AddDays(-2) } + } + 'Win32_ComputerSystem' { + return [pscustomobject]@{ PartOfDomain = $true; Domain = 'corp.local'; Workgroup = $null + UserName = 'CORP\user'; TotalPhysicalMemory = 17179869184 } + } + 'Win32_BIOS' { return [pscustomobject]@{ ReleaseDate = [datetime]'2021-03-01' } } + 'Win32_LogicalDisk' { return [pscustomobject]@{ FreeSpace = 100GB; Size = 250GB } } + 'AntiVirusProduct' { + return @([pscustomobject]@{ displayName = 'Windows Defender' }, + [pscustomobject]@{ displayName = 'Bitdefender Endpoint Security Tools' }) + } + 'MSFT_MpComputerStatus' { + if ($s -eq 'defender-stopped') { throw 'The service cannot be started (0x800106ba)' } + if ($s -eq 'defender-removed') { throw 'Invalid namespace' } + $sig = if ($s -eq 'cim-no-sig-date') { $null } else { (Get-Date).AddHours(-5) } + return [pscustomobject]@{ AMServiceEnabled = $true; RealTimeProtectionEnabled = $true + AntivirusSignatureLastUpdated = $sig } + } + 'Win32_ComputerSystemProduct' { + if ($s -in 'no-uuid', 'no-uuid-no-guid', 'wmi-down') { throw 'Generic failure' } + if ($s -eq 'zero-uuid') { return [pscustomobject]@{ UUID = '00000000-0000-0000-0000-000000000000' } } + return [pscustomobject]@{ UUID = $Script:Uuid } + } + 'Win32_SystemEnclosure' { return [pscustomobject]@{ ChassisTypes = @(3) } } + 'Win32_EncryptableVolume' { return [pscustomobject]@{ ProtectionStatus = 1 } } + default { throw "unmocked CIM class $ClassName" } + } +} +function Get-MpComputerStatus { + param($ErrorAction) + if ($Script:Scenario -eq 'healthy') { + return [pscustomobject]@{ AMServiceEnabled = $true; RealTimeProtectionEnabled = $true + AntivirusSignatureLastUpdated = (Get-Date).AddHours(-3) } + } + throw 'Get-MpComputerStatus: module could not be loaded (SYSTEM, -NoProfile)' +} +function Get-Service { + param($Name, $ErrorAction) + if ($Name -eq 'WinDefend') { + if ($Script:Scenario -eq 'defender-removed') { return $null } + $st = if ($Script:Scenario -eq 'defender-stopped') { 'Stopped' } else { 'Running' } + return [pscustomobject]@{ Name = 'WinDefend'; Status = $st } + } + return $null +} +function Get-BitLockerVolume { param($MountPoint, $ErrorAction) [pscustomobject]@{ ProtectionStatus = 'On' } } +function Get-ItemProperty { + param($Path, $Name, $ErrorAction) + if ("$Path" -match 'Cryptography') { + if ($Script:Scenario -eq 'no-uuid-no-guid') { throw 'Cannot find path' } + return [pscustomobject]@{ MachineGuid = $Script:MachineGuid } + } + if ("$Path" -match 'Real-Time Protection') { throw 'Property DisableRealtimeMonitoring does not exist' } + return $null +} +function New-Object { + param($TypeName, $ComObject, $ArgumentList, $ErrorAction) + if ($ComObject) { + $count = if ($Script:Scenario -eq 'wu-history-empty') { 0 } else { 1 } + $hist = [pscustomobject]@{ Count = $count } + $hist | Add-Member ScriptMethod Item { param($i) [pscustomobject]@{ Date = (Get-Date).AddDays(-6) } } + $srch = [pscustomobject]@{} + $srch | Add-Member ScriptMethod QueryHistory { param($a, $b) $hist }.GetNewClosure() + $sess = [pscustomobject]@{} + $sess | Add-Member ScriptMethod CreateUpdateSearcher { $srch }.GetNewClosure() + return $sess + } + Microsoft.PowerShell.Utility\New-Object -TypeName $TypeName +} + +Invoke-Expression $safeBlock +Invoke-Expression $biosDate + +# --- Scenarios -------------------------------------------------------------- +# EngineRuns: whether MachineInfo should be populated. Sigs/Wu: expected +# 'Defender Sigs' / 'Last WU Install' ('date' = any yyyy-MM-dd value). +$scenarios = @( + @{ Name = 'healthy'; Id = $Script:Uuid; EngineRuns = $true; Sigs = 'date'; Wu = 'date' } + @{ Name = 'defender-stopped'; Id = $Script:Uuid; EngineRuns = $true; Sigs = 'Unknown'; Wu = 'date' } + @{ Name = 'defender-removed'; Id = $Script:Uuid; EngineRuns = $true; Sigs = 'Unknown'; Wu = 'date' } + @{ Name = 'cim-no-sig-date'; Id = $Script:Uuid; EngineRuns = $true; Sigs = 'Unknown'; Wu = 'date' } + @{ Name = 'wu-history-empty'; Id = $Script:Uuid; EngineRuns = $true; Sigs = 'date'; Wu = 'Unknown' } + @{ Name = 'engine-aborts'; Id = $Script:Uuid; EngineRuns = $false } + @{ Name = 'engine-disabled'; Id = $Script:Uuid; EngineRuns = $false } + @{ Name = 'zero-uuid'; Id = $Script:MachineGuid; EngineRuns = $true; Sigs = 'date'; Wu = 'date' } + @{ Name = 'no-uuid'; Id = $Script:MachineGuid; EngineRuns = $true; Sigs = 'date'; Wu = 'date' } + @{ Name = 'no-uuid-no-guid'; Id = 'host:SK-TEST-PC'; EngineRuns = $true; Sigs = 'date'; Wu = 'date' } + # WMI down: the old in-engine code never reached MachineGuid here, so the + # id must stay host: rather than re-enroll under a new id. + @{ Name = 'wmi-down'; Id = 'host:SK-TEST-PC'; EngineRuns = $false } +) + +$failures = 0 +function Fail([string]$Label, [string]$Why) { + Write-Host " FAIL $Label - $Why" -ForegroundColor Red + $script:failures++ +} + +Write-Host '' +Write-Host ' Device identity and Assessment Engine (Phase 2, StrictMode 2)' +Write-Host ' ------------------------------------------------------------' + +foreach ($sc in $scenarios) { + $Script:Scenario = $sc.Name + $Script:Config.AssessmentEngine_Enabled = ($sc.Name -ne 'engine-disabled') + $Script:Logged.Clear() + Remove-Variable -Name DeviceId -Scope Script -ErrorAction SilentlyContinue + + $ErrorActionPreference = 'SilentlyContinue' # as ShellKnight.ps1 runs + try { Invoke-Expression $phase2 } + finally { $ErrorActionPreference = 'Stop' } + $label = $sc.Name + $before = $failures + + # device_id as the payload builds it (read without throwing if unset). + $payloadId = Get-Variable -Name DeviceId -Scope Script -ValueOnly -ErrorAction SilentlyContinue + if ($null -eq $payloadId -or "$payloadId" -eq '') { Fail $label 'device_id is null/empty' } + elseif ($payloadId -ne $sc.Id) { Fail $label "device_id '$payloadId', expected '$($sc.Id)'" } + + $skipped = @($Script:Logged | Where-Object { $_ -match 'skipped' }) + if ($sc.EngineRuns) { + if ($skipped.Count) { Fail $label "engine aborted: $($skipped -join ' | ')" } + elseif ($Script:MachineInfo.Count -eq 0) { Fail $label 'MachineInfo is empty' } + else { + if ($Script:MachineInfo['Device ID'] -ne $payloadId) { Fail $label "MachineInfo 'Device ID' '$($Script:MachineInfo['Device ID'])' differs from device_id" } + foreach ($pair in @(@('Defender Sigs', $sc.Sigs), @('Last WU Install', $sc.Wu))) { + $v = "$($Script:MachineInfo[$pair[0]])" + $ok = if ($pair[1] -eq 'date') { $v -match '^\d{4}-\d{2}-\d{2}' } else { $v -eq $pair[1] } + if (-not $ok) { Fail $label "'$($pair[0])' = '$v', expected $($pair[1])" } + } + } + } elseif ($sc.Name -in 'engine-aborts', 'wmi-down' -and -not $skipped.Count) { + Fail $label 'expected the engine to abort in this scenario (test harness check)' + } + + if ($failures -eq $before) { + $note = if ($sc.EngineRuns) { "engine ok, sigs=$($Script:MachineInfo['Defender Sigs'])" } else { "engine did not run; MachineInfo.Count=$($Script:MachineInfo.Count)" } + Write-Host " ok $label - device_id=$payloadId; $note" -ForegroundColor Green + } +} + +# --- Static wiring: the payload and the POST -------------------------------- +if ($source -notmatch '(?m)^\s+device_id\s+=\s+\$Script:DeviceId\s*$') { + Fail 'payload' 'device_id is not read from $Script:DeviceId' +} else { Write-Host ' ok payload - device_id = $Script:DeviceId' -ForegroundColor Green } + +$post = [regex]::Match($source, '(?s)Invoke-RestMethod -Uri \$Script:Config\.BattlefieldURL -Method Post.*?-ErrorAction Stop') +if (-not $post.Success) { + Fail 'POST' 'Battlefield Invoke-RestMethod call not found' +} elseif ($post.Value -notmatch [regex]::Escape('-Body ([System.Text.Encoding]::UTF8.GetBytes($jsonBody))') -or + $post.Value -notmatch [regex]::Escape("-ContentType 'application/json; charset=utf-8'")) { + Fail 'POST' 'report is not sent as UTF-8 bytes with charset=utf-8 (PS 5.1 would send ISO-8859-1)' +} else { Write-Host ' ok POST - UTF-8 byte[] body, charset=utf-8' -ForegroundColor Green } + +$env:COMPUTERNAME = $origComputerName + +Write-Host '' +if ($failures -gt 0) { + Write-Host " FAILED - $failures assertion(s)" -ForegroundColor Red + exit 1 +} +Write-Host ' PASS - all assertions' -ForegroundColor Green +exit 0