From 41f8fc8a97825c7d7ca35d318f3913b3ffbf2b47 Mon Sep 17 00:00:00 2001 From: cdburgess75 <508435+cdburgess75@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:50:54 -0500 Subject: [PATCH] v2026.09.25.003: OS end of life by build and edition, with Microsoft's dates The Assessment Engine looked up os_eol by build number only, 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 read 2027 and 2028; 26100 read 2029-10-14. One date per build cannot be right either: Home/Pro and Enterprise/Education end on different days, and 14393, 17763, 19044 and 26100 are also LTSB/LTSC or Windows Server. New Get-OsEolDate takes the edition family from Win32_OperatingSystem .Caption (Home/Pro, Enterprise/Education, LTSB/LTSC, IoT LTSC, Server) and holds every date from Microsoft Learn. A caption it cannot place gets a date only when it holds for every edition the machine could be; otherwise 'Unknown', which is not scored (ADR 0009). os_eol keeps its three forms. Windows 10 ESU does not extend end of life (new ADR 0010). Scoring change: more devices take the OS EOL -20 (Windows 10 22H2, Windows 11 22H2, 11 23H2 Home/Pro, older GA releases, Server 23H2). New tests/Test-OsEol.ps1; Test-EngineScope and Test-DeviceIdentity load the new function, and EngineScope adds a same-build Pro/LTSC pair. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 7 + CONTEXT.md | 8 + ShellKnight.ps1 | 123 ++++++++-- docs/adr/0010-os-end-of-life-by-edition.md | 184 ++++++++++++++ tests/Test-DeviceIdentity.ps1 | 2 + tests/Test-EngineScope.ps1 | 19 +- tests/Test-OsEol.ps1 | 269 +++++++++++++++++++++ 7 files changed, 583 insertions(+), 29 deletions(-) create mode 100644 docs/adr/0010-os-end-of-life-by-edition.md create mode 100644 tests/Test-OsEol.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d48454..2c5841a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # ShellKnight Changelog +## [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. +- **Windows 10 ESU does not extend end of life (ADR 0010):** a Windows 10 device reports `END OF LIFE (since 2025-10-14)` and takes the -20 whether or not it is enrolled in Extended Security Updates. Microsoft's end of support is 2025-10-14. ESU is a per-device licence that ShellKnight cannot see for consumer or cloud-granted enrolments, and commercial Year 1 ends on 2026-10-13. The ADR records the reasoning and when to revisit it. +- **Scoring change, downward for most devices it touches:** from the first run of this version the OS EOL -20 also applies to every Windows 10 22H2 and Windows 11 22H2 device, to Windows 11 23H2 Home/Pro, to the GA-channel releases of Windows 10 21H2, 1809 and 1607 and of Windows 11 21H2, and to Windows Server 23H2 (build 25398, ended 2025-10-24), which the old table did not know and so never scored. On 2026-10-13 it reaches Windows 11 24H2 Home/Pro and Windows 10 2016 LTSB; on 2026-11-10, Windows 11 23H2 Enterprise/Education. Windows 10 Enterprise LTSC 2021 and IoT Enterprise LTSC 2021 keep their later dates (2027-01-12 and 2032-01-13), where the old table would have taken 20 points from them on 2026-10-13. Nothing changed on the endpoints. +- **Regression test:** new `tests/Test-OsEol.ps1` runs `Get-OsEolDate` against Microsoft's date for every build and edition family with real captions; it also runs the unplaced-caption rule and the engine's `os_eol` lines with the -20 rule, with the clock pinned either side of an end date. It checks that every date is a Patch Tuesday and that no build the old table knew is dropped. `Test-EngineScope.ps1` adds a Pro/LTSC pair on the same build through the whole engine, and its healthy fixture moves to a build supported until 2034. `Test-EngineScope.ps1` and `Test-DeviceIdentity.ps1` load the new function. + ## [v2026.09.25.002] - 2026-09-25 - **An unknown password minimum length is no longer scored or reported as 0:** `$Script:MinPasswordLen` started at 0, and only the Assessment Engine's 'Password policy' check set it, by parsing `net accounts`. When the engine aborted or was disabled, or `net accounts` gave no 'Minimum password length' value, the scoring took 20 points and the CIS Benchmark block added the High finding `Password minimum length is 0 (CIS 1.1.1)`. Battlefield raises an alert for every High finding and maps that title to the VULN `password-policy-blank`, so a collection failure was scored and alerted as a vulnerability, which ADR 0009 rules out. The value now starts at `$null`. The scoring and the CIS 1.1.1 check skip it when it is `$null`, and the log says the length is unknown. A length that was read, including a real 0, is scored and reported exactly as before. diff --git a/CONTEXT.md b/CONTEXT.md index d8f691f..0bce00e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -67,6 +67,14 @@ The per-device score ShellKnight computes during a Run, 0 to 100, published on t a letter grade. Scoped to one machine and one Run. It is NOT the number the customer sees in a report; that is the Tenant Security Score. +### OS End of Life + +The date Microsoft stops servicing a device's Windows release, for that build AND edition: Home/Pro, +Enterprise/Education, LTSB/LTSC, IoT Enterprise LTSC or Server. Reported as `os_eol`; once passed, +it costs the Device Security Score 20 points. It is Microsoft's end-of-servicing date. It is NOT +moved by an Extended Security Updates licence: Windows 10 is end of life from 2025-10-14 with or +without ESU. See [ADR 0010](docs/adr/0010-os-end-of-life-by-edition.md). + ### Tenant Security Score The single customer-level number, 0 to 100 internally, presented to the customer as a letter diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index a25c013..e65ede1 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -2,7 +2,7 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - ShellKnight v2026.09.25.002 - Enterprise Endpoint Security & Remediation Tool + ShellKnight v2026.09.25.003 - 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.002 + Version : v2026.09.25.003 Released : 2026-09-25 - Prior : v2026.09.25.001 + Prior : v2026.09.25.002 .ENGINES Phase 1 - Intel Engine : Threat intelligence download and cache @@ -33,6 +33,30 @@ Phase 8 - Reporting Engine : Reporting, trending, and extended checks .CHANGELOG + 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) + read 2030-10-14 for 2025-10-14, 22621 and 22631 (Windows 11 22H2 + and 23H2) read 2027 and 2028, 26100 read 2029-10-14. And a build + is often several products: Home/Pro and Enterprise/Education end + on different days, and 14393, 17763, 19044 and 26100 are also + LTSB/LTSC or Windows Server, which run for years longer. New + Get-OsEolDate takes the edition from the caption (Home/Pro, + Enterprise/Education, LTSB/LTSC, IoT LTSC, Server) and holds every + date from Microsoft Learn. A caption it cannot place (localized, + say) gets a date only if it holds for every edition the machine + could be; otherwise 'Unknown', which is not scored (ADR 0009). + New builds: 25398 (Server 23H2), 26200 (Windows 11 25H2) and + 28000 (26H1). os_eol keeps its three forms. + Windows 10 ESU does not extend end of life: a Windows 10 device is + END OF LIFE since 2025-10-14, enrolled or not (ADR 0010). + SCORING CHANGE. More devices take the OS EOL -20 from the first + run: Windows 10 22H2 and 11 22H2 (all editions), 11 23H2 Home/Pro, + the GA releases of 10 21H2, 1809, 1607 and 11 21H2, and Server + 23H2 (25398, which the old table did not know). Windows 11 + 24H2 Home/Pro follows on 2026-10-13 and 23H2 Enterprise/Education + on 2026-11-10. Enterprise LTSC 2021 and IoT LTSC keep their later + dates, where the old table would have ended them on 2026-10-13. v2026.09.25.002 - An unknown password minimum length is no longer scored or reported as 0. $Script:MinPasswordLen started at 0, and only the engine's 'Password policy' check set it, from 'net accounts'. So @@ -473,7 +497,7 @@ param() # ============================================================================== -# SHELLKNIGHT v2026.09.25.002 CONFIGURATION +# SHELLKNIGHT v2026.09.25.003 CONFIGURATION # All settings are configured here. No external config files required. # Each engine can be independently enabled or disabled. # ============================================================================== @@ -666,7 +690,7 @@ try { # Runtime Config Object - single source of truth for all engines $Script:Config = [PSCustomObject]@{ - Version = 'v2026.09.25.002' + Version = 'v2026.09.25.003' # Intel Engine IntelEngine_Enabled = $SK_IntelEngine_Enabled IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates @@ -954,6 +978,70 @@ function ConvertTo-BiosDate { return (Get-Date) # unknown age; scores treat this as a new machine } +# Microsoft's end-of-servicing date for this Windows build AND edition, or +# $null when it is not known. The build alone is not enough: Home/Pro and +# Enterprise/Education reach end of servicing on different days, and 7601, +# 9200, 9600, 10240, 14393, 17763, 19044 and 26100 are each several products +# (client GA, LTSB/LTSC, IoT LTSC, Server) with dates years apart. Until +# v2026.09.25.003 the table was keyed by build only and used one date per +# build, some of them years past Microsoft's (19045 read 2030 for 2025). +# Dates are the last Patch Tuesday of servicing, from Microsoft Learn release +# health and the lifecycle pages (whose tables show the next day, 6:59:59 AM). +# GA versions: end of servicing. LTSB/LTSC and Server: end of extended +# support. Windows 10 ESU does not extend a date (ADR 0010). +# The edition comes from the English caption. A caption this cannot place +# (localized, or an edition not listed for the build) gets a date only when it +# holds for every edition the machine could be: all past, or all the same. +# Otherwise $null ('Unknown'), which is not scored (ADR 0009). +function Get-OsEolDate { + param([string]$Caption, [string]$Build, [datetime]$Now = (Get-Date)) + # Order matters: an LTSC caption also says Enterprise, and 'Pro Education' + # is on the Home/Pro timeline, so Pro is tested before Education. + $family = if ($Caption -match 'Server') { 'Server' } + elseif ($Caption -match 'LTS[BC]') { if ($Caption -match 'IoT') { 'IoTLTSC' } else { 'LTSC' } } + elseif ($Caption -match '\bPro\b|\bHome\b|\bSE\b') { 'HomePro' } + elseif ($Caption -match 'Enterprise|Education') { 'EntEdu' } + else { $null } + # HomePro: Home, Pro, Pro Education, Pro for Workstations, SE. + # EntEdu: Enterprise, Education, IoT Enterprise (GA), Enterprise multi-session. + $eol = @{ + '7601' = @{ HomePro = '2020-01-14'; EntEdu = '2020-01-14'; Server = '2020-01-14' } # Windows 7 SP1 / Server 2008 R2 + '9200' = @{ HomePro = '2016-01-12'; EntEdu = '2016-01-12'; Server = '2023-10-10' } # Windows 8 / Server 2012 + '9600' = @{ HomePro = '2023-01-10'; EntEdu = '2023-01-10'; Server = '2023-10-10' } # Windows 8.1 / Server 2012 R2 + '10240' = @{ HomePro = '2017-05-09'; EntEdu = '2017-05-09'; LTSC = '2025-10-14' } # 10 1507 / 2015 LTSB + '10586' = @{ HomePro = '2017-10-10'; EntEdu = '2017-10-10' } # 10 1511 + '14393' = @{ HomePro = '2018-04-10'; EntEdu = '2019-04-09'; LTSC = '2026-10-13'; IoTLTSC = '2026-10-13'; Server = '2027-01-12' } # 10 1607 / 2016 LTSB / Server 2016 + '15063' = @{ HomePro = '2018-10-09'; EntEdu = '2019-10-08' } # 10 1703 + '16299' = @{ HomePro = '2019-04-09'; EntEdu = '2020-10-13' } # 10 1709 + '17134' = @{ HomePro = '2019-11-12'; EntEdu = '2021-05-11' } # 10 1803 + '17763' = @{ HomePro = '2020-11-10'; EntEdu = '2021-05-11'; LTSC = '2029-01-09'; IoTLTSC = '2029-01-09'; Server = '2029-01-09' } # 10 1809 / LTSC 2019 / Server 2019 + '18362' = @{ HomePro = '2020-12-08'; EntEdu = '2020-12-08' } # 10 1903 + '18363' = @{ HomePro = '2021-05-11'; EntEdu = '2022-05-10' } # 10 1909 + '19041' = @{ HomePro = '2021-12-14'; EntEdu = '2021-12-14' } # 10 2004 + '19042' = @{ HomePro = '2022-05-10'; EntEdu = '2023-05-09' } # 10 20H2 + '19043' = @{ HomePro = '2022-12-13'; EntEdu = '2022-12-13' } # 10 21H1 + '19044' = @{ HomePro = '2023-06-13'; EntEdu = '2024-06-11'; LTSC = '2027-01-12'; IoTLTSC = '2032-01-13' } # 10 21H2 / LTSC 2021 + '19045' = @{ HomePro = '2025-10-14'; EntEdu = '2025-10-14' } # 10 22H2 (ESU: ADR 0010) + '20348' = @{ Server = '2031-10-14' } # Server 2022 + '22000' = @{ HomePro = '2023-10-10'; EntEdu = '2024-10-08' } # 11 21H2 + '22621' = @{ HomePro = '2024-10-08'; EntEdu = '2025-10-14' } # 11 22H2 + '22631' = @{ HomePro = '2025-11-11'; EntEdu = '2026-11-10' } # 11 23H2 + '25398' = @{ Server = '2025-10-24' } # Server 23H2 (Annual Channel) + '26100' = @{ HomePro = '2026-10-13'; EntEdu = '2027-10-12'; LTSC = '2029-10-09'; IoTLTSC = '2034-10-10'; Server = '2034-11-14' } # 11 24H2 / LTSC 2024 / Server 2025 + '26200' = @{ HomePro = '2027-10-12'; EntEdu = '2028-10-10' } # 11 25H2 + '28000' = @{ HomePro = '2028-03-14'; EntEdu = '2029-03-13' } # 11 26H1 + } + $row = $eol[$Build] + if (-not $row) { return $null } + if ($family -and $row.ContainsKey($family)) { return [datetime]$row[$family] } + # Not placed: every date this machine could have, on its side of the + # client/server line (drawn as the engine draws HWInfo.IsServer). + $isServer = $Caption -match 'Server' + $dates = @($row.Keys | Where-Object { ($_ -eq 'Server') -eq $isServer } | ForEach-Object { [datetime]$row[$_] } | Sort-Object) + if ($dates.Count -and ($Now -gt $dates[-1] -or $dates[0] -eq $dates[-1])) { return $dates[-1] } + return $null +} + # Get folder size in bytes function Get-FolderSizeBytes { param([string]$Path) @@ -1056,7 +1144,7 @@ $Script:UseNewPSFeatures = $Script:PSVer -ge 5 # Banner $bannerWidth = 78 -$version = 'ShellKnight v2026.09.25.002' +$version = 'ShellKnight v2026.09.25.003' $hostname = $env:COMPUTERNAME $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $psver = "PS $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" @@ -1345,21 +1433,8 @@ if ($Script:Config.AssessmentEngine_Enabled) { } catch { } } - # OS EOL check - $eolDates = @{ - '7601' = [datetime]'2020-01-14'; '9200' = [datetime]'2023-10-10' - '9600' = [datetime]'2023-10-10'; '10240'= [datetime]'2025-10-14' - '10586' = [datetime]'2017-10-10'; '14393'= [datetime]'2027-01-12' - '15063' = [datetime]'2018-10-09'; '16299'= [datetime]'2019-04-09' - '17134' = [datetime]'2019-11-12'; '17763'= [datetime]'2029-01-09' - '18362' = [datetime]'2020-05-12'; '18363'= [datetime]'2021-05-11' - '19041' = [datetime]'2025-10-14'; '19042'= [datetime]'2025-10-14' - '19043' = [datetime]'2025-10-14'; '19044'= [datetime]'2026-10-13' - '19045' = [datetime]'2030-10-14'; '20348'= [datetime]'2031-10-14' - '22000' = [datetime]'2026-10-14'; '22621'= [datetime]'2027-10-12' - '22631' = [datetime]'2028-10-10'; '26100'= [datetime]'2029-10-14' - } - $eolDate = $eolDates[$osBuild] + # OS EOL check: Microsoft's date for this build and edition + $eolDate = Get-OsEolDate -Caption $osName -Build $osBuild $eolStr = if ($eolDate) { if ((Get-Date) -gt $eolDate) { $Script:OsEolWarn = $true; "END OF LIFE (since $($eolDate.ToString('yyyy-MM-dd')))"} else { "Supported until $($eolDate.ToString('yyyy-MM-dd'))" } @@ -3397,7 +3472,7 @@ $freeAfterGB = if ($diskAfter) { [math]::Round($diskAfter.FreeSpace / 1GB, 1) } $sepLine = '=' * 80 Log-Info $sepLine -Log-Info " ShellKnight v2026.09.25.002 - Report" +Log-Info " ShellKnight v2026.09.25.003 - Report" Log-Info " Hostname : $($env:COMPUTERNAME)" Log-Info " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Log-Info " Runtime : $runtime seconds" @@ -3410,7 +3485,7 @@ Log-Info $sepLine $bannerWidth2 = 78 Write-Host '' Write-Host " $sepLine" -ForegroundColor Cyan -Write-Host " ShellKnight v2026.09.25.002 - Report" -ForegroundColor Cyan +Write-Host " ShellKnight v2026.09.25.003 - 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 @@ -3682,7 +3757,7 @@ $jsonStamp= Get-Date -Format 'yyyy-MM-dd_HHmm' $jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json" $jsonData = [ordered]@{ - version = 'v2026.09.25.002' + version = 'v2026.09.25.003' device_id = $Script:DeviceId hardware_type = $Script:MachineInfo['Hardware Type'] site_name = $SK_SiteName diff --git a/docs/adr/0010-os-end-of-life-by-edition.md b/docs/adr/0010-os-end-of-life-by-edition.md new file mode 100644 index 0000000..50f4678 --- /dev/null +++ b/docs/adr/0010-os-end-of-life-by-edition.md @@ -0,0 +1,184 @@ +# 0010: OS end of life is Microsoft's date for the build and edition; ESU does not extend it + +**Status:** Accepted + +**Date:** 2026-09-25 + +## Context + +ShellKnight reports `os_eol` for every device: `END OF LIFE (since )`, `Supported until +`, or `Unknown`. Battlefield prints it in two places: + +- **The customer report.** `bf/report.py` counts a host as supported unless the string contains + `END OF LIFE`. The report's "every operating system is within its supported lifetime" positive + depends on that count. +- **The device page.** + +From v2026.09.25.001 an end-of-life OS also costs the device 20 points. + +Until v2026.09.25.003 the date came from a table keyed by build number alone, with one date per +build. That design had two problems. + +**The dates were wrong.** Several were years past Microsoft's: + +| Build | Table said | Microsoft | +|---|---|---| +| 19045 (Windows 10 22H2) | 2030-10-14 | 2025-10-14 | +| 22621 (Windows 11 22H2) | 2027-10-12 | 2024-10-08 (Home/Pro), 2025-10-14 (Enterprise/Education) | +| 22631 (Windows 11 23H2) | 2028-10-10 | 2025-11-11 (Home/Pro), 2026-11-10 (Enterprise/Education) | + +**One date per build cannot be right.** + +- **Home/Pro and Enterprise/Education are serviced for different lengths.** On Windows 11, + Home/Pro gets 24 months and Enterprise/Education gets 36. +- **Some builds are several products at once:** + - 14393, 17763, 19044 and 26100 are each also an LTSB/LTSC release; + - 14393, 17763 and 26100 are also Windows Server 2016, 2019 and 2025; + - 7601, 9200 and 9600 are both a client and a server release. +- **The dates within one build can be years apart.** Build 26100 ends on: + - 2026-10-13 for Windows 11 24H2 Home/Pro; + - 2027-10-12 for 24H2 Enterprise/Education; + - 2029-10-09 for Enterprise LTSC 2024; + - 2034-10-10 for IoT Enterprise LTSC 2024; + - 2034-11-14 for Windows Server 2025. + +**Windows 10 ended on 2025-10-14, for every edition.** Microsoft sells Extended Security Updates +(ESU) past that date. +- **Commercial ESU** is licensed per device, in one-year terms ending 2026-10-13, 2027-10-12 and + 2028-10-10. It covers Pro, Enterprise and Education in commercial use, on 22H2 only. +- **Consumer ESU** is documented only outside Microsoft Learn; the Learn ESU pages send home users + to microsoft.com. Nothing here relies on its terms. +- **Windows 365 and Azure-hosted Windows 10** get ESU without a key. + +## Decision + +**The date is Microsoft's, looked up by build *and* edition.** +- `Get-OsEolDate` holds one row per build, with a date for each edition family that build ships as: + - Home/Pro; + - Enterprise/Education; + - LTSB/LTSC; + - IoT Enterprise LTSC; + - Server. +- The source is Microsoft Learn: + - the release-health pages for Windows 10, Windows 11 and Windows Server; + - `learn.microsoft.com/lifecycle/products/...` for retired versions, LTSB/LTSC and Server. +- **Which date a row holds:** + - GA-channel versions use their end-of-servicing date. + - LTSB/LTSC and Server use their end of extended support. + - The date is the Patch Tuesday of the last update. The lifecycle pages show it as the next day + at 6:59:59 AM. +- **The edition families follow Microsoft's own edition lists.** + - Home/Pro includes Pro Education, Pro for Workstations and SE. + - Enterprise/Education includes IoT Enterprise (GA channel) and Enterprise multi-session. + +**The edition comes from `Win32_OperatingSystem.Caption`.** +- The caption is tested in this order: + 1. `Server`; + 2. `LTSB`/`LTSC`, split by `IoT`; + 3. the words `Pro`, `Home` or `SE`; + 4. `Enterprise` or `Education`. +- The order matters. An LTSC caption also says Enterprise, and "Pro Education" is on the Home/Pro + timeline. + +**A caption that cannot be placed is given a date only when the date is true whatever the edition +is.** +- This covers a localized caption and an edition the build does not ship as. +- The lookup considers every edition the machine could be, on its side of the client/server line. + - If they all share one date, it uses that date. + - If they have all ended, it uses the latest date. + - Otherwise the result is `Unknown`, which is not scored. +- This is [ADR 0009](0009-customer-facing-security-score.md) applied to edition detection. Failing + to identify the edition must never cost a device points. + +**Windows 10 ESU does not extend end of life.** A Windows 10 22H2 device reports `END OF LIFE (since +2025-10-14)` and takes the -20, whether or not it is enrolled in ESU. The same rule already applied +to Windows 7, Server 2008 R2 and Server 2012/2012 R2, which all had ESU. + +- **This is Microsoft's own position.** Windows 10 reached end of support on 2025-10-14. ESU is a + paid bridge with a fixed end, and it carries security fixes only. The score asks whether the OS + is supported, and for Windows 10 the answer is no. +- **ESU is a licence on one device, not a fact about the OS.** + - To see it, ShellKnight would need a new licensing probe. + - Microsoft documents `slmgr /dlv` with the ESU activation IDs for commercial MAK activation. + - It documents no local check for consumer ESU, and none for the Windows 365 and Azure grants. + - So a probe would score two identical devices differently, depending only on how their ESU was + bought. +- **An exemption would make the penalty depend on a probe.** A device whose ESU check failed or + could not see its licence would lose the 20 points. That is a collection failure moving a risk + score, which ADR 0009 rules out. +- **Timing.** Commercial ESU Year 1 ends on 2026-10-13, 18 days after this decision. An exemption + for it would expire about when it shipped. Only paid Year 2 and Year 3 licences would benefit. + +## Consequences + +**Good:** + +- The report and the score now agree with the dates Microsoft publishes. The PR that makes this + change lists every date and its source, and `tests/Test-OsEol.ps1` pins them. +- **A server or LTSC build is no longer given a client's date, and a client is no longer given + theirs.** + - Server 2016 and Server 2019 keep their later dates. + - Windows 10 1607/1809 Pro no longer reports "supported until 2027/2029". + - Windows 11 24H2 Pro no longer reads 2029-10-14; its date is 2026-10-13. +- A misread edition can only cost the device a date. It can never cost it points. + +**Bad:** + +- **More devices take the -20 from the day this ships:** + - every Windows 10 22H2 device; + - every Windows 11 22H2 device; + - Windows 11 23H2 Home/Pro; + - the GA-channel releases of Windows 10 21H2, 1809 and 1607, and of Windows 11 21H2; + - Windows Server 23H2 (build 25398), which the old table did not know. + + The calendar then adds more: + - Windows 11 24H2 Home/Pro and Windows 10 2016 LTSB on 2026-10-13; + - Windows 11 23H2 Enterprise/Education on 2026-11-10; + - Windows 10 Enterprise LTSC 2021 and Windows Server 2016 on 2027-01-12. + + Grades will drop with no change on the endpoints, so explain this before a scorecard goes out. +- **An ESU-enrolled Windows 10 device loses 20 points and shows END OF LIFE.** That device is + receiving security updates. Its patch state is still visible in the Windows Update field and + rule. +- **Every new Windows release needs a table row.** Until it gets one, the device reads `Unknown` and + is not scored. `Test-OsEol` checks that every date is a Patch Tuesday, which catches most typos. +- **The caption can be localized.** On a non-English Windows, devices fall back to the + unplaced-caption rule and read `Unknown` more often. The PR's impact query shows each device's + edition family, so an unplaced caption in the fleet shows up as an empty `family`. + +**Revisit ESU if ParaTech sells ESU Year 2 or Year 3 (after 2026-10-13) to a customer.** +- Detect the commercial licence by its documented activation ID and report it as its own field. +- Only then decide whether a detected licence should soften the score, weighing that against the + probe-dependence argument above. + +## Alternatives considered + +### Treat ESU-eligible Windows 10 as supported until the ESU end date + +Rejected. Most Windows 10 devices are not enrolled. It would hide the largest end-of-life population +in the fleet behind a licence nobody bought. + +### Detect ESU and exempt enrolled devices + +Rejected for now, for the reasons under **Decision**: +- consumer ESU and the Windows 365 and Azure grants cannot be seen locally; +- a failed probe would cost points; +- an exemption for Year 1 licences would expire on 2026-10-13. + +### Keep one date per build, choosing the latest or the earliest + +Rejected, because both choices are wrong for some devices. +- **The latest date** puts every Windows 11 24H2 Pro device on Server 2025's 2034 date. +- **The earliest date** takes 20 points from every Server 2016 and 2019 box, and from every LTSC + device. + +### Detect the edition from `OperatingSystemSKU` instead of the caption + +Deferred. +- **For:** the SKU number is documented and not localized. +- **Against:** + - The mapping is long: every N, evaluation and IoT variant has its own number. + - The fleet is expected to be English-language, and the impact query shows whether it is. + - A caption that cannot be placed already falls back to a date that is safe for every edition. + +It is the natural next step if non-English devices appear. diff --git a/tests/Test-DeviceIdentity.ps1 b/tests/Test-DeviceIdentity.ps1 index 6799b05..3cb728d 100644 --- a/tests/Test-DeviceIdentity.ps1 +++ b/tests/Test-DeviceIdentity.ps1 @@ -38,6 +38,7 @@ function Get-Section { $safeBlock = Get-Section '(?ms)^function Invoke-SafeBlock \{.*?^\}' 'Invoke-SafeBlock' $biosDate = Get-Section '(?ms)^function ConvertTo-BiosDate \{.*?^\}' 'ConvertTo-BiosDate' +$osEol = Get-Section '(?ms)^function Get-OsEolDate \{.*?^\}' 'Get-OsEolDate' # 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*$.*?' + @@ -139,6 +140,7 @@ function New-Object { Invoke-Expression $safeBlock Invoke-Expression $biosDate +Invoke-Expression $osEol # --- Scenarios -------------------------------------------------------------- # EngineRuns: whether MachineInfo should be populated. Sigs/Wu: expected diff --git a/tests/Test-EngineScope.ps1 b/tests/Test-EngineScope.ps1 index bb56981..6d19480 100644 --- a/tests/Test-EngineScope.ps1 +++ b/tests/Test-EngineScope.ps1 @@ -53,6 +53,7 @@ function Get-Section { $safeBlock = Get-Section '(?ms)^function Invoke-SafeBlock \{.*?^\}' 'Invoke-SafeBlock' $biosDate = Get-Section '(?ms)^function ConvertTo-BiosDate \{.*?^\}' 'ConvertTo-BiosDate' +$osEol = Get-Section '(?ms)^function Get-OsEolDate \{.*?^\}' 'Get-OsEolDate' # Phase 2 from the MachineInfo reset to the end of the engine's if/else. $phase2 = Get-Section ('(?ms)^\$Script:MachineInfo = \[ordered\]@\{\}\s*$.*?' + '^ Log-Info "Assessment Engine - disabled"\s*^\}') 'Phase 2 (the Assessment Engine)' @@ -186,6 +187,7 @@ function Get-NetFirewallProfile { param($ErrorAction) @([pscustomobject]@{ Profi Invoke-Expression $safeBlock Invoke-Expression $biosDate +Invoke-Expression $osEol # --- Scenarios -------------------------------------------------------------- # The machine each mock describes, and what the payload and the score must say. @@ -194,11 +196,14 @@ Invoke-Expression $biosDate # for Av/Edr/Def means the engine did not run, so the payload has no value to # report. Pw is the CIS 1.1.1 finding's title, or $null for none. Len is the # payload's password_min_length: the length read, or $null when it was not. -$healthy = @{ Engine = 'runs'; Caption = 'Microsoft Windows 11 Pro'; Build = '22631'; BitLocker = 'On'; WuDays = 6 +# The healthy machine's OS is supported until 2034-10-10 (Get-OsEolDate), so +# this fixture does not age into end of life. Tests/Test-OsEol.ps1 covers the +# edition dates themselves. +$healthy = @{ Engine = 'runs'; Caption = 'Microsoft Windows 11 IoT Enterprise LTSC'; Build = '26100'; BitLocker = 'On'; WuDays = 6 AvList = @('Windows Defender'); Defender = 'active'; Services = @(); Net = 'ok'; PwLen = 14 } function New-Scenario([string]$Name, [hashtable]$Machine, [hashtable]$Expect) { $m = $healthy.Clone(); foreach ($k in $Machine.Keys) { $m[$k] = $Machine[$k] } - $e = @{ Av = 'Windows Defender'; Edr = 'None detected'; Def = 'Active'; Penalty = 0; Finding = $false; Pw = $null; Len = 14 } + $e = @{ Av = 'Windows Defender'; Edr = 'None detected'; Def = 'Active'; Penalty = 0; Finding = $false; Pw = $null; Len = 14; Eol = $false } foreach ($k in $Expect.Keys) { $e[$k] = $Expect[$k] } $m.Name = $Name; $m.Expect = $e; $m } @@ -208,9 +213,13 @@ $scenarios = @( New-Scenario 'bitlocker-off-cim' @{ BitLocker = 'Off-cim' } @{ Penalty = 15; Finding = $true } # Neither probe answers: unknown, so no penalty (ADR 0009). New-Scenario 'bitlocker-unavailable' @{ BitLocker = 'unavailable' } @{} - New-Scenario 'os-eol' @{ Caption = 'Microsoft Windows 10 Pro'; Build = '19043' } @{ Penalty = 20 } + New-Scenario 'os-eol' @{ Caption = 'Microsoft Windows 10 Pro'; Build = '19043' } @{ Penalty = 20; Eol = $true } + # One build, two editions, two answers: the engine must look up the + # caption it read, not just the build. Stable until 2029-01-09. + New-Scenario 'os-eol-1809-pro' @{ Caption = 'Microsoft Windows 10 Pro'; Build = '17763' } @{ Penalty = 20; Eol = $true } + New-Scenario 'os-eol-1809-ltsc' @{ Caption = 'Microsoft Windows 10 Enterprise LTSC'; Build = '17763' } @{} New-Scenario 'wu-stale' @{ WuDays = 45 } @{ Penalty = 15 } - New-Scenario 'all-three' @{ BitLocker = 'Off'; Caption = 'Microsoft Windows 10 Pro'; Build = '19043'; WuDays = 45 } @{ Penalty = 50; Finding = $true } + New-Scenario 'all-three' @{ BitLocker = 'Off'; Caption = 'Microsoft Windows 10 Pro'; Build = '19043'; WuDays = 45 } @{ Penalty = 50; Finding = $true; Eol = $true } # Windows turns Defender off when a third-party AV registers. Protected: # no penalty (the old Defender DISABLED rule would have taken 20). New-Scenario 'third-party-av' @{ AvList = @('Windows Defender', 'Bitdefender Endpoint Security Tools'); Defender = 'off' } @{ Av = 'Bitdefender Endpoint Security Tools'; Def = 'DISABLED' } @@ -282,7 +291,7 @@ foreach ($sc in $scenarios) { $wantBl = if ($sc.BitLocker -eq 'On') { 'On' } elseif ($sc.BitLocker -eq 'unavailable') { 'Not available' } else { 'Off' } if ($payload['bitlocker'] -ne $wantBl) { Fail $label "payload bitlocker = $(Show $payload['bitlocker']), expected '$wantBl'" } $eol = "$($payload['os_eol'])" -like 'END OF LIFE*' - if ($eol -ne ($sc.Build -eq '19043')) { Fail $label "payload os_eol = $(Show $payload['os_eol'])" } + if ($eol -ne $x.Eol) { Fail $label "payload os_eol = $(Show $payload['os_eol']), expected $(if ($x.Eol) { 'END OF LIFE' } else { 'not END OF LIFE' })" } } $score = 100 - $x.Penalty diff --git a/tests/Test-OsEol.ps1 b/tests/Test-OsEol.ps1 new file mode 100644 index 0000000..d1c5cd1 --- /dev/null +++ b/tests/Test-OsEol.ps1 @@ -0,0 +1,269 @@ +<# +.SYNOPSIS + Regression test: the OS end-of-life date is Microsoft's, for this build AND + this edition. + +.DESCRIPTION + Up to v2026.09.25.002 the Assessment Engine looked up end of life by build + number only, with one date per build. Several were years past Microsoft's + (19045, Windows 10 22H2, read 2030-10-14 for 2025-10-14), and one date + cannot be right for a build that ships as several products: Home/Pro and + Enterprise/Education end on different days, and 14393, 17763, 19044 and + 26100 are also LTSB/LTSC and Windows Server, which run for years longer. + The date is printed in customer reports (os_eol) and, from v2026.09.25.001, + an end-of-life build costs 20 points. + + This checks, all verbatim from ShellKnight.ps1: + - Get-OsEolDate against Microsoft's dates for every build and edition + family it knows, with real Win32_OperatingSystem captions. The expected + dates are restated here from Microsoft Learn, not read from the script. + - A caption it cannot place (a localized one, say): a date only when it + holds for every edition the machine could be, otherwise unknown, which + is not scored (ADR 0009). + - The engine's 'OS EOL' lines and the -20 rule, with the clock pinned + either side of an end date, and the os_eol text Battlefield depends on + (bf/report.py counts a host as supported unless it says 'END OF LIFE'). + - The table itself: every build the old table knew is still known, and + every date is a Patch Tuesday (a mistyped day almost never is). + + Every expectation uses a pinned date, so the test does not age. The whole + engine runs in tests/Test-EngineScope.ps1; this 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 +} + +$osEolFn = Get-Section '(?ms)^function Get-OsEolDate \{.*?^\}' 'Get-OsEolDate' +# The engine's lines from the lookup to the 'OS EOL' string. +$engine = Get-Section ("(?ms)^ \`$eolDate = Get-OsEolDate -Caption \`$osName -Build \`$osBuild\s*$.*?" + + "^ \} else \{ 'Unknown' \}") "the engine's OS EOL check" +$rule = Get-Section '(?m)^if \(\$Script:OsEolWarn\)\s+\{ \$Script:SecurityScore -= 20 \}' 'the OS EOL scoring rule' +# ...and what the engine feeds it. +$null = Get-Section '(?m)^ \$osName = \$os\.Caption\s*$' 'the engine taking $osName from $os.Caption' +$null = Get-Section '(?m)^ \$osBuild = \$os\.BuildNumber\s*$' 'the engine taking $osBuild from $os.BuildNumber' + +# --- 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 } +# The clock. Get-OsEolDate's default -Now and the engine's verdict both call it. +$Script:Today = [datetime]'2026-09-25' +function Get-Date { $Script:Today } + +Invoke-Expression $osEolFn + +$failures = 0 +function Fail([string]$Label, [string]$Why) { + Say " FAIL $Label - $Why" Red + $script:failures++ +} +function Show($v) { if ($null -eq $v) { '' } else { "'$v'" } } + +# --- 1. Get-OsEolDate: Microsoft's date for each build and edition ---------- +# Caption | Build | expected date ($null: unknown) | why. Sources, all Microsoft +# Learn: windows/release-health/release-information (Windows 10), +# .../windows11-release-information, .../windows-server-release-info, and +# lifecycle/products/ for retired versions, LTSB/LTSC and Server. +$cases = @( + # The dates the old build-only table got wrong. + ,@('Microsoft Windows 10 Pro', '19045', '2025-10-14', '10 22H2: was 2030-10-14') + ,@('Microsoft Windows 10 Enterprise', '19045', '2025-10-14', '10 22H2 Enterprise: same day; ESU does not extend it (ADR 0010)') + ,@('Microsoft Windows 11 Pro', '22621', '2024-10-08', '11 22H2 Home/Pro: was 2027-10-12') + ,@('Microsoft Windows 11 Enterprise', '22621', '2025-10-14', '11 22H2 Enterprise') + ,@('Microsoft Windows 11 Pro', '22631', '2025-11-11', '11 23H2 Home/Pro: was 2028-10-10') + ,@('Microsoft Windows 11 Enterprise', '22631', '2026-11-10', '11 23H2 Enterprise') + ,@('Microsoft Windows 11 Pro', '26100', '2026-10-13', '11 24H2 Home/Pro: was 2029-10-14') + ,@('Microsoft Windows 11 Enterprise', '26100', '2027-10-12', '11 24H2 Enterprise') + ,@('Microsoft Windows 11 Enterprise LTSC', '26100', '2029-10-09', '11 Enterprise LTSC 2024 (no extended phase)') + ,@('Microsoft Windows 11 IoT Enterprise LTSC', '26100', '2034-10-10', '11 IoT Enterprise LTSC 2024') + ,@('Microsoft Windows Server 2025 Standard', '26100', '2034-11-14', 'Server 2025') + ,@('Microsoft Windows 11 Pro', '22000', '2023-10-10', '11 21H2 Home/Pro: was 2026-10-14') + ,@('Microsoft Windows 11 Education', '22000', '2024-10-08', '11 21H2 Education') + ,@('Microsoft Windows 10 Pro', '19044', '2023-06-13', '10 21H2 Home/Pro: was 2026-10-13') + ,@('Microsoft Windows 10 Enterprise', '19044', '2024-06-11', '10 21H2 Enterprise') + ,@('Microsoft Windows 10 Enterprise LTSC', '19044', '2027-01-12', '10 Enterprise LTSC 2021 (no extended phase)') + ,@('Microsoft Windows 10 IoT Enterprise LTSC', '19044', '2032-01-13', '10 IoT Enterprise LTSC 2021') + ,@('Microsoft Windows 10 Pro', '14393', '2018-04-10', '10 1607 Home/Pro: was 2027-01-12') + ,@('Microsoft Windows 10 Enterprise', '14393', '2019-04-09', '10 1607 Enterprise') + ,@('Microsoft Windows 10 Enterprise 2016 LTSB', '14393', '2026-10-13', '10 Enterprise 2016 LTSB') + ,@('Microsoft Windows 10 IoT Enterprise 2016 LTSB','14393','2026-10-13', '10 IoT Enterprise 2016 LTSB') + ,@('Microsoft Windows Server 2016 Standard', '14393', '2027-01-12', 'Server 2016') + ,@('Microsoft Windows 10 Pro', '17763', '2020-11-10', '10 1809 Home/Pro: was 2029-01-09') + ,@('Microsoft Windows 10 Enterprise', '17763', '2021-05-11', '10 1809 Enterprise') + ,@('Microsoft Windows 10 Enterprise LTSC', '17763', '2029-01-09', '10 Enterprise LTSC 2019') + ,@('Microsoft Windows 10 IoT Enterprise LTSC', '17763', '2029-01-09', '10 IoT Enterprise LTSC 2019') + ,@('Microsoft Windows Server 2019 Datacenter', '17763', '2029-01-09', 'Server 2019') + ,@('Microsoft Hyper-V Server 2019', '17763', '2029-01-09', 'Hyper-V Server 2019 follows Server 2019') + ,@('Microsoft Windows 10 Pro', '10240', '2017-05-09', '10 1507: was 2025-10-14, the LTSB date') + ,@('Microsoft Windows 10 Enterprise 2015 LTSB', '10240', '2025-10-14', '10 Enterprise 2015 LTSB') + ,@('Microsoft Windows 10 Home', '18362', '2020-12-08', '10 1903: was 2020-05-12') + ,@('Microsoft Windows 10 Enterprise', '19041', '2021-12-14', '10 2004: was 2025-10-14') + ,@('Microsoft Windows 10 Enterprise', '19042', '2023-05-09', '10 20H2 Enterprise: was 2025-10-14') + ,@('Microsoft Windows 10 Pro', '19043', '2022-12-13', '10 21H1: was 2025-10-14') + ,@('Microsoft Windows 10 Enterprise', '16299', '2020-10-13', '10 1709 Enterprise: was 2019-04-09, the Home/Pro date') + ,@('Microsoft Windows 8 Pro', '9200', '2016-01-12', 'Windows 8: was 2023-10-10, the Server 2012 date') + ,@('Microsoft Windows 8.1 Pro', '9600', '2023-01-10', 'Windows 8.1: was 2023-10-10, the Server 2012 R2 date') + # The rest of the table. + ,@('Microsoft Windows Server 2008 R2 Standard', '7601', '2020-01-14', 'Server 2008 R2') + ,@('Microsoft Windows 7 Enterprise ', '7601', '2020-01-14', 'Windows 7 SP1 (caption as documented, trailing space)') + ,@('Microsoft Windows Server 2012 Datacenter', '9200', '2023-10-10', 'Server 2012') + ,@('Microsoft Windows Server 2012 R2 Standard', '9600', '2023-10-10', 'Server 2012 R2') + ,@('Microsoft Windows 10 Education', '10586', '2017-10-10', '10 1511') + ,@('Microsoft Windows 10 Pro', '15063', '2018-10-09', '10 1703 Home/Pro') + ,@('Microsoft Windows 10 Education', '15063', '2019-10-08', '10 1703 Education') + ,@('Microsoft Windows 10 Pro', '16299', '2019-04-09', '10 1709 Home/Pro') + ,@('Microsoft Windows 10 Pro', '17134', '2019-11-12', '10 1803 Home/Pro') + ,@('Microsoft Windows 10 Enterprise', '17134', '2021-05-11', '10 1803 Enterprise') + ,@('Microsoft Windows 10 Pro', '18363', '2021-05-11', '10 1909 Home/Pro') + ,@('Microsoft Windows 10 Enterprise', '18363', '2022-05-10', '10 1909 Enterprise') + ,@('Microsoft Windows 10 Pro', '19042', '2022-05-10', '10 20H2 Home/Pro') + ,@('Microsoft Windows Server 2022 Standard', '20348', '2031-10-14', 'Server 2022') + ,@('Microsoft Windows Server Datacenter', '25398', '2025-10-24', 'Server 23H2, Annual Channel') + ,@('Microsoft Windows 11 Pro', '26200', '2027-10-12', '11 25H2 Home/Pro') + ,@('Microsoft Windows 11 Enterprise', '26200', '2028-10-10', '11 25H2 Enterprise') + ,@('Microsoft Windows 11 Pro', '28000', '2028-03-14', '11 26H1 Home/Pro') + ,@('Microsoft Windows 11 Enterprise', '28000', '2029-03-13', '11 26H1 Enterprise') + # Which timeline each edition follows (Microsoft's own edition lists). + ,@('Microsoft Windows 10 Home Single Language', '22631', '2025-11-11', 'Home Single Language: Home/Pro') + ,@('Microsoft Windows 11 Pro for Workstations', '22631', '2025-11-11', 'Pro for Workstations: Home/Pro') + ,@('Microsoft Windows 11 Pro Education', '22631', '2025-11-11', 'Pro Education: Home/Pro, though it says Education') + ,@('Microsoft Windows 11 SE', '26100', '2026-10-13', 'SE: Home/Pro') + ,@('Microsoft Windows 11 Education', '22631', '2026-11-10', 'Education: Enterprise/Education') + ,@('Microsoft Windows 11 Enterprise multi-session','22631','2026-11-10', 'Enterprise multi-session: Enterprise/Education') + ,@('Microsoft Windows 11 IoT Enterprise', '22631', '2026-11-10', 'IoT Enterprise (GA, not LTSC): Enterprise/Education') + ,@('Microsoft Windows 10 Enterprise N', '19044', '2024-06-11', 'N edition: its base edition') + # A caption it cannot place (as of 2026-09-25): a date only if it holds for + # every edition the machine could be. + ,@('Microsoft Windows 10 Professionnel', '19043', '2022-12-13', 'unplaced, every edition ended the same day') + ,@('Microsoft Windows 10 Professionnel', '17134', '2021-05-11', 'unplaced, every edition past: the latest date') + ,@('Microsoft Windows 11 Professionnel', '22631', $null, 'unplaced, Enterprise 23H2 still supported: unknown') + ,@('Microsoft Windows 11 Entreprise', '26100', $null, 'unplaced 24H2: could be anything from Pro to IoT LTSC') + ,@('Microsoft Windows 10 Professionnel', '17763', $null, 'unplaced 1809: could be LTSC 2019 (2029)') + ,@('Microsoft Windows 7 Professional', '7601', '2020-01-14', 'unplaced ("Professional" is not "Pro"), every client edition ended the same day') + ,@('Microsoft Windows 8.1', '9600', '2023-01-10', 'unplaced core 8.1: client date, not the Server 2012 R2 one') + ,@('', '19045', '2025-10-14', 'no caption at all, every edition ended the same day') + ,@('Microsoft Windows 10 Enterprise LTSC', '19045', '2025-10-14', 'an edition this build does not ship as: treated as unplaced') + ,@('Microsoft Windows Server 2022 Standard', '22631', $null, 'a server caption on a client-only build: unknown') + ,@('Microsoft Azure Stack HCI', '20348', $null, 'a client-side caption on a server-only build: unknown') + # Builds it does not know. + ,@('Microsoft Windows Server 2008 Standard', '6003', $null, 'build not in the table') + ,@('Microsoft Windows 11 Pro', '', $null, 'no build') +) + +Say '' +Say ' Get-OsEolDate: Microsoft end of servicing by build and edition (as of 2026-09-25)' +Say ' --------------------------------------------------------------------------------' +foreach ($c in $cases) { + $caption, $build, $want, $why = $c + $label = "$build $(Show $caption)" + try { + $got = Get-OsEolDate -Caption $caption -Build $build + } catch { Fail $label "threw: $($_.Exception.Message)"; continue } + if ($null -ne $got -and $got -isnot [datetime]) { Fail $label "returned $($got.GetType().Name), expected DateTime or null"; continue } + $gotS = if ($null -eq $got) { $null } else { $got.ToString('yyyy-MM-dd') } + if ($gotS -ne $want -or ($null -eq $gotS) -ne ($null -eq $want)) { Fail $label "$(Show $gotS), expected $(Show $want) ($why)"; continue } + Say " ok $label -> $(Show $gotS) ($why)" Green +} + +# The unplaced rule is about "now": the same machine becomes known once even +# the longest-lived edition it could be has ended. +$Script:Today = [datetime]'2026-11-11' +$got = Get-OsEolDate -Caption 'Microsoft Windows 11 Professionnel' -Build '22631' +if ("$got" -eq '' -or $got.ToString('yyyy-MM-dd') -ne '2026-11-10') { Fail 'unplaced 22631 on 2026-11-11' "$(Show $got), expected '2026-11-10'" } +else { Say " ok unplaced 22631 on 2026-11-11 -> '2026-11-10' (every client edition has ended)" Green } +$Script:Today = [datetime]'2026-09-25' + +# --- 2. The engine's os_eol and the -20 -------------------------------------- +# Caption | Build | clock | os_eol exactly | penalty +$engineCases = @( + ,@('Microsoft Windows 10 Pro', '19045', '2026-09-25', 'END OF LIFE (since 2025-10-14)', 20) + ,@('Microsoft Windows 10 Enterprise', '19045', '2026-09-25', 'END OF LIFE (since 2025-10-14)', 20) + ,@('Microsoft Windows 11 Pro', '22631', '2026-09-25', 'END OF LIFE (since 2025-11-11)', 20) + ,@('Microsoft Windows 11 Enterprise', '22631', '2026-09-25', 'Supported until 2026-11-10', 0) + ,@('Microsoft Windows 11 Enterprise', '22631', '2026-11-11', 'END OF LIFE (since 2026-11-10)', 20) + ,@('Microsoft Windows 11 Pro', '26100', '2026-10-12', 'Supported until 2026-10-13', 0) + ,@('Microsoft Windows 11 Pro', '26100', '2026-10-14', 'END OF LIFE (since 2026-10-13)', 20) + ,@('Microsoft Windows 11 IoT Enterprise LTSC','26100','2026-10-14', 'Supported until 2034-10-10', 0) + ,@('Microsoft Windows Server 2025 Standard', '26100', '2026-10-14', 'Supported until 2034-11-14', 0) + ,@('Microsoft Windows Server 2016 Standard', '14393', '2026-09-25', 'Supported until 2027-01-12', 0) + ,@('Microsoft Windows 10 Pro', '14393', '2026-09-25', 'END OF LIFE (since 2018-04-10)', 20) + ,@('Microsoft Windows 11 Entreprise', '26100', '2026-09-25', 'Unknown', 0) + ,@('Microsoft Windows 11 Pro', '99999', '2026-09-25', 'Unknown', 0) +) + +Say '' +Say ' Engine: os_eol and the OS EOL rule (StrictMode 2)' +Say ' -------------------------------------------------' +foreach ($c in $engineCases) { + $osName, $osBuild, $today, $want, $penalty = $c + $label = "$osBuild $(Show $osName) on $today" + $Script:Today = [datetime]$today + $Script:OsEolWarn = $false + $Script:SecurityScore = 100 + $ErrorActionPreference = 'SilentlyContinue' # as ShellKnight.ps1 runs + try { + Invoke-Expression $engine + Invoke-Expression $rule + } finally { $ErrorActionPreference = 'Stop' } + $before = $failures + if ($eolStr -cne $want) { Fail $label "os_eol $(Show $eolStr), expected $(Show $want)" } + if ($Script:SecurityScore -ne 100 - $penalty) { Fail $label "score $($Script:SecurityScore), expected $(100 - $penalty)" } + # bf/report.py: a host is "supported" unless os_eol contains END OF LIFE. + if (($eolStr -clike '*END OF LIFE*') -ne [bool]$penalty) { Fail $label "Battlefield would count this host as $(if ($penalty) { 'supported' } else { 'end of life' })" } + if ($eolStr -cnotmatch '^(END OF LIFE \(since \d{4}-\d{2}-\d{2}\)|Supported until \d{4}-\d{2}-\d{2}|Unknown)$') { Fail $label "os_eol $(Show $eolStr) is not one of the three forms Battlefield has always received" } + if ($failures -eq $before) { Say " ok $label -> $(Show $eolStr), -$penalty" Green } +} +$Script:Today = [datetime]'2026-09-25' + +# --- 3. The table ------------------------------------------------------------ +Say '' +Say ' Get-OsEolDate table' +Say ' -------------------' +$rows = @{} +foreach ($r in [regex]::Matches($osEolFn, "(?m)^\s*'(\d{4,5})'\s*=\s*@\{([^}]*)\}")) { + $rows[$r.Groups[1].Value] = @([regex]::Matches($r.Groups[2].Value, "(\w+)\s*=\s*'([^']*)'") | ForEach-Object { + [pscustomobject]@{ Family = $_.Groups[1].Value; Date = $_.Groups[2].Value } }) +} +if ($rows.Count -lt 20) { Fail 'table' "found $($rows.Count) builds - did the table's layout change? (this check reads it textually)" } + +# No build the old table knew may fall back to 'Unknown'. +$oldBuilds = '7601 9200 9600 10240 10586 14393 15063 16299 17134 17763 18362 18363 19041 19042 19043 19044 19045 20348 22000 22621 22631 26100' -split ' ' +$missing = @($oldBuilds | Where-Object { -not $rows.ContainsKey($_) }) +if ($missing.Count) { Fail 'table' "builds the old table knew are gone: $($missing -join ', ')" } +else { Say " ok all $($oldBuilds.Count) builds of the old table are still known ($($rows.Count) in all)" Green } + +# Families are the five the caption can produce; dates are Patch Tuesdays +# (the second Tuesday), except the Server Annual Channel, whose end is fixed. +$families = 'HomePro', 'EntEdu', 'LTSC', 'IoTLTSC', 'Server' +$notPatchTuesday = @{ '25398/Server' = '2025-10-24' } +$bad = 0 +foreach ($b in $rows.Keys) { + foreach ($e in $rows[$b]) { + $key = "$b/$($e.Family)" + if ($e.Family -notin $families) { Fail 'table' "$key - unknown edition family"; $bad++; continue } + $d = [datetime]::MinValue + if (-not [datetime]::TryParseExact($e.Date, 'yyyy-MM-dd', [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$d)) { Fail 'table' "$key - '$($e.Date)' is not a yyyy-MM-dd date"; $bad++; continue } + $pt = $d.DayOfWeek -eq 'Tuesday' -and $d.Day -ge 8 -and $d.Day -le 14 + if (-not $pt -and $notPatchTuesday[$key] -ne $e.Date) { Fail 'table' "$key - $($e.Date) is a $($d.DayOfWeek), not a Patch Tuesday (typo?)"; $bad++ } + } +} +if (-not $bad) { Say ' ok every date is a real date and a Patch Tuesday (Server 23H2 excepted, as documented)' Green } + +Say '' +if ($failures -gt 0) { + Say " FAILED - $failures assertion(s)" Red + exit 1 +} +Say ' PASS - all assertions' Green +exit 0