diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5841a..fdf131b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # ShellKnight Changelog +## [v2026.09.26.001] - 2026-09-26 + +- **Event 7045 allow-list: Claude, ChatGPT/Codex and Malwarebytes.** The first run at CustomerF (HOST-F1) scored F (0/100) on 10 IOCs. Eight of them were Event 7045 service installs by legitimate software that re-registers its services on every update: Claude's `cowork-svc` and OpenAI's Codex sandbox service (registered as "ChatGPT"), both Microsoft Store packages under `C:\Program Files\WindowsApps`, and three Malwarebytes kernel drivers (`mbam.sys`, `mwac.sys`, `mbae.sys`). Each IOC costs 15 points, capped at 50. +- **Allowed by what cannot be borrowed, never by the service name.** The Store apps are matched on the package folder, anchored at `X:\Program Files\WindowsApps\` and ending in the publisher ID (`__pzs8sxrjxfjjc` for Claude, `__2p2nqsd0c76g0` for OpenAI) that the package's signing certificate determines; only the system can write there. The Malwarebytes drivers install to a bare `system32\drivers` path with no vendor folder, so they are matched by service name *and* exact driver file in the real Windows driver directory, through a new `$knownGoodSvcDrivers` table. That is stronger than the name-only entries used for the Avira drivers. A service merely named "Claude" or "ChatGPT", another publisher's package, a folder called `WindowsApps` anywhere else, or `mbam.sys` under another service name or directory still raises the IOC. +- **Scoring change, upward only:** devices running these apps stop losing up to 50 points to them. No device loses points. +- **Regression test:** new `tests/Test-Svc7045Allowlist.ps1` runs the Event log IOC block verbatim with mocked events: CustomerF's eight events, the other path forms a driver event records, existing entries, and thirteen look-alikes that must still alert. + ## [v2026.09.25.003] - 2026-09-25 - **OS end of life is Microsoft's date for the build and the edition:** the Assessment Engine looked up `os_eol` by build number only, with one date per build, and several dates were years past Microsoft's. 19045 (Windows 10 22H2) read 2030-10-14 for 2025-10-14; 22621 and 22631 (Windows 11 22H2 and 23H2) read 2027-10-12 and 2028-10-10, later than even their Enterprise dates; 26100 read 2029-10-14. One date per build also cannot be right: Home/Pro and Enterprise/Education reach end of servicing on different days, and 14393, 17763, 19044 and 26100 are also LTSB/LTSC releases or Windows Server 2016/2019/2025, which run for years longer. The new `Get-OsEolDate` takes the edition family from `Win32_OperatingSystem.Caption` (Home/Pro, Enterprise/Education, LTSB/LTSC, IoT Enterprise LTSC, Server) and holds every date from Microsoft Learn's release-health and lifecycle pages. A caption it cannot place, such as a localized one, gets a date only when that date holds for every edition the machine could be; otherwise `os_eol` is `Unknown`, which is not scored (ADR 0009). New builds: 25398 (Server 23H2), 26200 (Windows 11 25H2) and 28000 (Windows 11 26H1). `os_eol` keeps its three forms, so Battlefield needs no change. diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index b06386f..b71dda5 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -2,7 +2,7 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - ShellKnight v2026.09.25.003 - Enterprise Endpoint Security & Remediation Tool + ShellKnight v2026.09.26.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.25.003 - Released : 2026-09-25 - Prior : v2026.09.25.002 + Version : v2026.09.26.001 + Released : 2026-09-26 + Prior : v2026.09.25.003 .ENGINES Phase 1 - Intel Engine : Threat intelligence download and cache @@ -33,6 +33,23 @@ Phase 8 - Reporting Engine : Reporting, trending, and extended checks .CHANGELOG + v2026.09.26.001 - Event 7045 allow-list: Claude, ChatGPT/Codex and + Malwarebytes. The first run at CustomerF (HOST-F1) scored + F (0/100) on 10 IOCs, 8 of them service installs by legitimate + software that re-registers its services on every update: Claude's + cowork-svc and OpenAI's Codex sandbox service (named "ChatGPT"), + both Microsoft Store packages, and three Malwarebytes kernel + drivers. Each IOC costs 15 points, capped at 50. Allowed by what + cannot be borrowed, never by the service name: the Store apps by + package folder, anchored at X:\Program Files\WindowsApps and ending + in the publisher ID that the signing certificate determines; the + Malwarebytes drivers by service name AND exact driver file in the + real system32\drivers directory (new $knownGoodSvcDrivers, stronger + than the name-only Avira entries). A service merely named "Claude", + another publisher's package, a WindowsApps folder elsewhere, or + mbam.sys under another name or directory still raises the IOC. + SCORING CHANGE, upward only: devices running these apps lose up to + 50 fewer points. v2026.09.25.003 - OS end of life is Microsoft's date for the build AND the edition. The engine looked it up by build number only, one date per build, and several were years late: 19045 (Windows 10 22H2) @@ -497,7 +514,7 @@ param() # ============================================================================== -# SHELLKNIGHT v2026.09.25.003 CONFIGURATION +# SHELLKNIGHT v2026.09.26.001 CONFIGURATION # All settings are configured here. No external config files required. # Each engine can be independently enabled or disabled. # ============================================================================== @@ -690,7 +707,7 @@ try { # Runtime Config Object - single source of truth for all engines $Script:Config = [PSCustomObject]@{ - Version = 'v2026.09.25.003' + Version = 'v2026.09.26.001' # Intel Engine IntelEngine_Enabled = $SK_IntelEngine_Enabled IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates @@ -1144,7 +1161,7 @@ $Script:UseNewPSFeatures = $Script:PSVer -ge 5 # Banner $bannerWidth = 78 -$version = 'ShellKnight v2026.09.25.003' +$version = 'ShellKnight v2026.09.26.001' $hostname = $env:COMPUTERNAME $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $psver = "PS $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" @@ -3067,15 +3084,45 @@ if ($Script:Config.ReportingEngine_Enabled) { 'windows defender', 'drivers\\wd\\', # Defender's driver directory (KslD.sys, WdAiNisDrv.sys) 'dell\\saremediation', # Dell factory remediation plugin (BioNTDrv) - field FP 2026-09-08 CUSTA - 'datto rollback driver' # Our own RMM rollback driver - field FP 2026-09-08 CUSTA + 'datto rollback driver', # Our own RMM rollback driver - field FP 2026-09-08 CUSTA + # Microsoft Store (MSIX) apps that register a service on every update: + # Claude's cowork-svc and OpenAI's Codex sandbox service (the latter + # named "ChatGPT") - field FP 2026-09-26 CustomerF. Keyed on the package + # folder, never the service name: WindowsApps is writable only by the + # system, and the folder ends in the publisher ID that the package's + # signing certificate determines, so another publisher's package, or a + # service merely named "Claude", does not match. Anchored at the start + # (after the event's leading quote) so a folder named WindowsApps + # somewhere else does not match either. + '^"?[a-z]:\\program files\\windowsapps\\claude_[^\\"]*__pzs8sxrjxfjjc\\', + '^"?[a-z]:\\program files\\windowsapps\\openai\.[^\\"]*__2p2nqsd0c76g0\\' ) + @($Script:Config.Svc7045_ExtraPaths | Where-Object { $_ }) + # Drivers that install to a bare system32\DRIVERS path, so there is no + # vendor directory to key on. Allowed only when the name AND the exact + # driver file both match, which is stronger than a name alone (the Avira + # entries above): a service called MBAMProtection anywhere else still + # alerts. Malwarebytes re-registers these on engine updates - field FP + # 2026-09-26 CustomerF. + # Service name -> driver file. Kernel-driver events record the path as + # C:\WINDOWS\system32\drivers\x.sys, \SystemRoot\System32\drivers\x.sys, + # \??\C:\..., or a bare System32\drivers\x.sys; $driverDir accepts those + # and nothing else, so a copy under C:\evil\system32\drivers\ still alerts. + $knownGoodSvcDrivers = @{ + 'MBAMProtection' = 'mbam.sys' + 'MBAMWebProtection' = 'mwac.sys' + 'Malwarebytes Anti-Exploit' = 'mbae.sys' + } + $driverDir = '^(\\\?\?\\)?([a-z]:\\windows\\|\\systemroot\\)?system32\\drivers\\' + $svcGroups = @{} foreach ($evt in $svcEvents) { $svcName = $evt.Properties[0].Value $svcPath = $evt.Properties[1].Value $svcAcct = $evt.Properties[4].Value if ($knownGoodSvcs.Contains($svcName)) { continue } + if ($knownGoodSvcDrivers.ContainsKey($svcName) -and + "$svcPath".Trim().Trim('"') -match ($driverDir + [regex]::Escape($knownGoodSvcDrivers[$svcName]) + '$')) { continue } # Path-based whitelist - skip events from known-good vendor install paths $isKnownGoodPath = $knownGoodSvcPaths | Where-Object { $svcPath -match $_ } if ($isKnownGoodPath) { continue } @@ -3472,7 +3519,7 @@ $freeAfterGB = if ($diskAfter) { [math]::Round($diskAfter.FreeSpace / 1GB, 1) } $sepLine = '=' * 80 Log-Info $sepLine -Log-Info " ShellKnight v2026.09.25.003 - Report" +Log-Info " ShellKnight v2026.09.26.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" @@ -3485,7 +3532,7 @@ Log-Info $sepLine $bannerWidth2 = 78 Write-Host '' Write-Host " $sepLine" -ForegroundColor Cyan -Write-Host " ShellKnight v2026.09.25.003 - Report" -ForegroundColor Cyan +Write-Host " ShellKnight v2026.09.26.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 @@ -3757,7 +3804,7 @@ $jsonStamp= Get-Date -Format 'yyyy-MM-dd_HHmm' $jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json" $jsonData = [ordered]@{ - version = 'v2026.09.25.003' + version = 'v2026.09.26.001' device_id = $Script:DeviceId hardware_type = $Script:MachineInfo['Hardware Type'] site_name = $SK_SiteName diff --git a/tests/Test-Svc7045Allowlist.ps1 b/tests/Test-Svc7045Allowlist.ps1 new file mode 100644 index 0000000..ac121df --- /dev/null +++ b/tests/Test-Svc7045Allowlist.ps1 @@ -0,0 +1,158 @@ +<# +.SYNOPSIS + Regression test: the Event 7045 (service install) check allows the Claude, + ChatGPT/Codex and Malwarebytes services it saw on CustomerF, and still alerts + on anything that only looks like them. + +.DESCRIPTION + On 2026-09-26 the first run on CustomerF's HOST-F1 reported 10 + IOCs. Eight were Event 7045 service installs by legitimate software that + re-registers its services on every update: Claude's cowork-svc and OpenAI's + Codex sandbox service (both Microsoft Store packages under + C:\Program Files\WindowsApps), and three Malwarebytes kernel drivers. Each + IOC costs the device 15 points (capped at 50), so the PC scored F (0/100). + + v2026.09.26.001 allows them, but keyed on what cannot be borrowed: + - The Store apps by package folder, anchored at X:\Program Files\WindowsApps + and ending in the publisher ID the signing certificate determines. + - The Malwarebytes drivers by service name AND exact driver file in the real + system32\drivers directory. + A service merely named "Claude", another publisher's package, a folder + called WindowsApps somewhere else, or mbam.sys under another name or in + another directory must still raise an IOC. + + This runs the 'Event log IOC' block verbatim from ShellKnight.ps1 under + StrictMode 2, with Get-WinEvent mocked to return one event per scenario. + 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 +$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' +$block = Get-Section "(?ms)^ Invoke-SafeBlock -Label 'Event log IOC' -Block \{.*?^ \}" "the 'Event log IOC' block" + +# --- Mocks. Functions take precedence over cmdlets of the same name. --------- +function Say { param([string]$m, [string]$c = 'Gray') Microsoft.PowerShell.Utility\Write-Host $m -ForegroundColor $c } +function Write-Host { } +$Script:Logged = New-Object 'System.Collections.Generic.List[string]' +$Script:IOCs = New-Object 'System.Collections.Generic.List[string]' +function Log-Info { param([string]$m) $Script:Logged.Add($m) } +function Log-Summary { param([string]$m) $Script:Logged.Add($m) } +function Log-Warn { param([string]$m) $Script:Logged.Add($m) } +function Log-Success { param([string]$m) $Script:Logged.Add($m) } +function Log-Fail { param([string]$m) $Script:Logged.Add($m) } +function Log-IOC { param([string]$m) $Script:IOCs.Add($m) } +# The ScreenConnect branch must not act in this test (SCRemoveRogue is off). +function Stop-Service { throw 'test: Stop-Service must not be called' } +function Remove-Item { throw 'test: Remove-Item must not be called' } +$Script:Config = [pscustomobject]@{ Svc7045_ExtraNames = @(); Svc7045_ExtraPaths = @(); SCInstanceID = ''; SCRemoveRogue = $false } +$Script:RogueScreenConnectRemoved = $false + +$Script:Events = @() +function Get-WinEvent { param($FilterHashtable, $ErrorAction) $Script:Events } +function New-SvcEvent([string]$Name, [string]$Path) { + # Event 7045 Properties: 0 service name, 1 image path, 2 type, 3 start, 4 account. + [pscustomobject]@{ + TimeCreated = (Get-Date).AddDays(-1) + Properties = @($Name, $Path, 'kernel mode driver', 'demand start', 'LocalSystem' | ForEach-Object { [pscustomobject]@{ Value = $_ } }) + } +} + +Invoke-Expression $safeBlock + +$failures = 0 +function Fail([string]$Label, [string]$Why) { + Say " FAIL $Label - $Why" Red + $script:failures++ +} + +function Invoke-Check([object[]]$Events) { + $Script:Events = $Events + $Script:Counters = @{ IOCsFound = 0; Failed = 0 } + $Script:Logged.Clear(); $Script:IOCs.Clear() + $ErrorActionPreference = 'SilentlyContinue' # as ShellKnight.ps1 runs + try { Invoke-Expression $block } finally { $ErrorActionPreference = 'Stop' } + $skipped = @($Script:Logged | Where-Object { $_ -match '^Event log IOC skipped' }) + if ($skipped.Count) { throw "the block aborted: $($skipped -join ' | ')" } + $Script:Counters.IOCsFound +} + +# --- Scenarios -------------------------------------------------------------- +# Name | Path (as the event records it) | expected IOCs | why +$wa = 'C:\Program Files\WindowsApps' +$cases = @( + # CustomerF, HOST-F1, 2026-09-26: the eight events, verbatim. + ,@('Claude', "`"$wa\Claude_2.2553.13.0_x64__pzs8sxrjxfjjc\app\resources\cowork-svc.exe`"", 0, 'Claude cowork-svc (CustomerF)') + ,@('Claude', "`"$wa\Claude_2.9939.2.0_x64__pzs8sxrjxfjjc\app\resources\cowork-svc.exe`"", 0, 'Claude cowork-svc, next version (CustomerF)') + ,@('Claude', "`"$wa\Claude_2.7032.0.0_x64__pzs8sxrjxfjjc\app\resources\cowork-svc.exe`"", 0, 'Claude cowork-svc, another version (CustomerF)') + ,@('ChatGPT', "`"$wa\OpenAI.Codex_26.917.9434.0_x64__2p2nqsd0c76g0\app\resources\codex-windows-sandbox-service.exe`"", 0, 'Codex sandbox service (CustomerF)') + ,@('ChatGPT', "`"$wa\OpenAI.Codex_26.915.4065.0_x64__2p2nqsd0c76g0\app\resources\codex-windows-sandbox-service.exe`"", 0, 'Codex sandbox service, other version (CustomerF)') + ,@('MBAMWebProtection', 'C:\WINDOWS\system32\DRIVERS\mwac.sys', 0, 'Malwarebytes web protection driver (CustomerF)') + ,@('Malwarebytes Anti-Exploit', 'C:\WINDOWS\system32\drivers\mbae.sys', 0, 'Malwarebytes anti-exploit driver (CustomerF)') + ,@('MBAMProtection', 'C:\WINDOWS\system32\DRIVERS\mbam.sys', 0, 'Malwarebytes protection driver (CustomerF)') + # The other forms a kernel-driver event records. + ,@('MBAMProtection', '\SystemRoot\System32\drivers\mbam.sys', 0, 'driver path as \SystemRoot\...') + ,@('MBAMProtection', 'System32\drivers\mbam.sys', 0, 'driver path as a bare System32\...') + ,@('MBAMProtection', '\??\C:\WINDOWS\system32\drivers\mbam.sys', 0, 'driver path as \??\C:\...') + ,@('mbamprotection', 'c:\windows\system32\drivers\MBAM.SYS', 0, 'names and paths compare case-insensitively') + # Same publisher, another OpenAI package: allowed by publisher, as intended. + ,@('ChatGPT', "`"$wa\OpenAI.ChatGPT-Desktop_1.2026.100.0_x64__2p2nqsd0c76g0\app\ChatGPT.exe`"", 0, 'OpenAI ChatGPT desktop package') + # Existing entries still work. + ,@('CentraStage', 'C:\Program Files (x86)\CentraStage\CagService.exe', 0, 'existing name entry (Datto RMM)') + ,@('GoogleUpdaterService140.0', '"C:\Program Files (x86)\Google\GoogleUpdater\140.0\updater.exe" --system --windows-service', 0, 'existing path entry (googleupdater)') + # Look-alikes that must still alert. + ,@('Claude', 'C:\Users\Public\cowork-svc.exe', 1, 'named "Claude", outside WindowsApps') + ,@('Claude', "`"$wa\Claude_2.0.0.0_x64__abcdefghijklm\cowork-svc.exe`"", 1, 'Claude package from another publisher ID') + ,@('Claude', "`"$wa\Claude_2.0.0.0_x64__pzs8sxrjxfjjcX\cowork-svc.exe`"", 1, 'publisher ID with a suffix') + ,@('Claude', '"C:\Users\Public\Program Files\WindowsApps\Claude_1_x64__pzs8sxrjxfjjc\x.exe"', 1, 'a folder called WindowsApps somewhere else') + ,@('Claude', '"C:\ProgramData\WindowsApps\Claude_1_x64__pzs8sxrjxfjjc\x.exe"', 1, 'WindowsApps outside Program Files') + ,@('ChatGPT', 'C:\Temp\codex.exe', 1, 'named "ChatGPT", outside WindowsApps') + ,@('ChatGPT', "`"$wa\OpenAI.Codex_1.0.0.0_x64__zzzzzzzzzzzzz\codex.exe`"", 1, 'OpenAI-looking package from another publisher ID') + ,@('MBAMProtection', 'C:\ProgramData\mbam.sys', 1, 'Malwarebytes name, driver outside system32') + ,@('MBAMProtection', 'C:\evil\system32\drivers\mbam.sys', 1, 'Malwarebytes name, a system32\drivers copy elsewhere') + ,@('MBAMProtection', 'C:\WINDOWS\system32\drivers\evil.sys', 1, 'Malwarebytes name, wrong driver file') + ,@('MBAMProtection', 'C:\WINDOWS\system32\drivers\mbam.sys.bak', 1, 'driver file with a suffix') + ,@('EvilSvc', 'C:\WINDOWS\system32\drivers\mbam.sys', 1, 'the Malwarebytes driver file under another name') + ,@('UpdateHelperSvc', 'C:\Users\Public\helper.exe', 1, 'an unknown service') +) + +Say '' +Say ' Event 7045 allow-list: one event per scenario (StrictMode 2)' +Say ' ------------------------------------------------------------' +foreach ($c in $cases) { + $name, $path, $want, $why = $c + $label = "$(if ($want) { 'alerts ' } else { 'allowed' }) $why" + try { $got = Invoke-Check @(New-SvcEvent $name $path) } catch { Fail $label $_.Exception.Message; continue } + if ($got -ne $want) { Fail $label "IOCs $got, expected $want ($name | $path)"; continue } + if ($want -and -not @($Script:IOCs | Where-Object { $_ -like "*Svc: $name*" }).Count) { Fail $label 'counted, but no Log-IOC line names the service'; continue } + Say " ok $label" Green +} + +# CustomerF's eight together, plus one unknown: exactly one IOC. +$customerf = @($cases | Select-Object -First 8 | ForEach-Object { New-SvcEvent $_[0] $_[1] }) + @(New-SvcEvent 'UpdateHelperSvc' 'C:\Users\Public\helper.exe') +try { + $got = Invoke-Check $customerf + if ($got -ne 1) { Fail 'customerf-run' "IOCs $got, expected 1 (the unknown service only)" } + else { Say ' ok CustomerF''s eight events plus one unknown service -> 1 IOC' Green } +} catch { Fail 'customerf-run' $_.Exception.Message } + +Say '' +if ($failures -gt 0) { + Say " FAILED - $failures assertion(s)" Red + exit 1 +} +Say ' PASS - all assertions' Green +exit 0