Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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:<name>`, 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:<name>`; 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:<name>` (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:<name>` 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.
Expand Down
111 changes: 85 additions & 26 deletions ShellKnight.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:<name>, 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:<name>, same values) now
runs in its own block before, and regardless of, the engine; its
result $Script:DeviceId starts at the host:<name> 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
Expand Down Expand Up @@ -401,7 +431,7 @@


# ==============================================================================
# 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.
# ==============================================================================
Expand Down Expand Up @@ -561,7 +591,7 @@
if ($cfg.ScheduleHours) { $SK_ScheduleHours = [int]$cfg.ScheduleHours }
if ($null -ne $cfg.SelfSchedule) { $SK_SelfSchedule = [bool]$cfg.SelfSchedule }
if ($cfg.SiteName) { $SK_SiteName = $cfg.SiteName }
} catch { }

Check warning on line 594 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 594 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}

# Environment-variable overrides (bootstrap via Datto sets these; env wins)
Expand Down Expand Up @@ -590,11 +620,11 @@
# back to the hardcoded IOC list (review finding 6b; field hit 2026-07-03).
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch { }

Check warning on line 623 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 623 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

# 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
Expand Down Expand Up @@ -877,7 +907,7 @@
# ParseExact rejects against 'yyyyMMdd', so the legacy path was broken too.)
$s = [string]$ReleaseDate
if ($s.Length -ge 8) {
try { return [datetime]::ParseExact($s.Substring(0, 8), 'yyyyMMdd', $null) } catch { }

Check warning on line 910 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 910 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}
return (Get-Date) # unknown age; scores treat this as a new machine
}
Expand Down Expand Up @@ -936,7 +966,7 @@
}
}
}
} catch { } # denied dir: skip it, continue with the rest of the stack

Check warning on line 969 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 969 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}
$sizes[$profileName] = $total
}
Expand All @@ -953,7 +983,7 @@
$beforeBytes = ($before | Measure-Object -Property Length -Sum).Sum
$removed = 0
foreach ($f in $before) {
try { Remove-Item -LiteralPath $f.FullName -Force -ErrorAction Stop; $removed++ } catch { }

Check warning on line 986 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 986 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}
if ($removed -gt 0) {
$freedMB = [math]::Round($beforeBytes / 1MB, 1)
Expand Down Expand Up @@ -984,7 +1014,7 @@

# 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)"
Expand Down Expand Up @@ -1059,7 +1089,7 @@
& schtasks.exe /Create /TN 'ShellKnight' /TR $action /SC HOURLY /MO $SK_ScheduleHours `
/ST $startTime /RU 'SYSTEM' /RL HIGHEST /F 2>$null | Out-Null
$Script:Health.task_ensured = ($LASTEXITCODE -eq 0)
try { $Script:Health.next_run = (Get-ScheduledTaskInfo -TaskName 'ShellKnight' -ErrorAction Stop).NextRunTime.ToString('o') } catch {}

Check warning on line 1092 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 1092 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
Log-Info "Self-schedule ensured: every $SK_ScheduleHours h at :$startTime (SYSTEM)"
} catch { Log-Warn "Self-schedule failed: $($_.Exception.Message)" }
}
Expand Down Expand Up @@ -1192,6 +1222,33 @@
$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:<name>. 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 { }

Check warning on line 1247 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 1247 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}
if ($deviceId) { $Script:DeviceId = $deviceId }
}

if ($Script:Config.AssessmentEngine_Enabled) {
Invoke-SafeBlock -Label 'Assessment Engine' -Block {

Expand Down Expand Up @@ -1235,7 +1292,7 @@
$blWmi = Get-CimInstance -Namespace 'Root\CIMV2\Security\MicrosoftVolumeEncryption' `
-ClassName 'Win32_EncryptableVolume' -Filter "DriveLetter='C:'" -ErrorAction Stop
$blStatus = if ($blWmi.ProtectionStatus -eq 1) { 'On' } else { 'Off'; $bitlockerWarn = $true }
} catch { }

Check warning on line 1295 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 1295 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}

# OS EOL check
Expand Down Expand Up @@ -1274,7 +1331,7 @@
if ($avName -match 'Windows Defender|Microsoft Defender') { $defenderRegistered = $true }
else { $avProducts.Add($avName) }
}
} catch { }

Check warning on line 1334 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 1334 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

# Datto AV (registered AV product; RMM handled elsewhere)
if (Get-Service -Name 'EndpointProtectionService2' -ErrorAction SilentlyContinue) {
Expand Down Expand Up @@ -1323,7 +1380,11 @@
# 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)
Expand All @@ -1342,7 +1403,7 @@
try {
$disableRtp = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection' `
-Name 'DisableRealtimeMonitoring' -ErrorAction Stop).DisableRealtimeMonitoring
} catch { }

Check warning on line 1406 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.

Check warning on line 1406 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
$defRtp = ($wd.Status -eq 'Running' -and $disableRtp -ne 1)
}
}
Expand All @@ -1359,8 +1420,10 @@
($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()
Expand All @@ -1380,18 +1443,7 @@
-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'
Expand All @@ -1409,7 +1461,7 @@

# Build machine info
$Script:MachineInfo = [ordered]@{
'Device ID' = $deviceId
'Device ID' = $Script:DeviceId
'Hardware Type' = $hwType
'Hostname' = $env:COMPUTERNAME
'OS' = "$osName (Build $osBuild)"
Expand Down Expand Up @@ -3276,7 +3328,7 @@
$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"
Expand All @@ -3289,7 +3341,7 @@
$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
Expand Down Expand Up @@ -3561,8 +3613,8 @@
$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
Expand Down Expand Up @@ -3626,8 +3678,15 @@
} 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
Expand Down
Loading
Loading