From 9ef5af2636646fb006b368742aa74c9449632c70 Mon Sep 17 00:00:00 2001 From: cdburgess75 <508435+cdburgess75@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:19:36 -0500 Subject: [PATCH 1/2] v2026.09.25.002: an unknown password length is not 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, it stayed 0. The scoring then took 20 points and the CIS block added the High finding "Password minimum length is 0 (CIS 1.1.1)". Battlefield alerts on every High finding and maps that title to the VULN password-policy-blank. That is a collection failure scored and alerted as a vulnerability, which ADR 0009 rules out. It now starts at $null and is set only from a number actually read ([int]'' is 0, so a length line with no number no longer parses as 0). The scoring and CIS 1.1.1 skip a $null length, and the log says it is unknown. Both guards are needed: $null -lt 8 is $true, so without them an unknown length would still cost 10 points and raise a finding with a blank length. A length that was read, including a real 0, is scored and reported exactly as before. tests/Test-EngineScope.ps1 now runs the CIS Benchmark block verbatim too and asserts the CIS 1.1.1 finding. The engine-aborts and engine-disabled scenarios no longer expect -20. New scenarios cover net accounts returning nothing, no length line, a length line with no number, and read lengths of 0, 6 and 10. It fails 10 assertions against v2026.09.25.001 and passes here. It is a mock test, not a Windows run. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 +++ ShellKnight.ps1 | 62 +++++++++++++++++++-------- tests/Test-EngineScope.ps1 | 85 ++++++++++++++++++++++++++++++-------- 3 files changed, 117 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd35cc0..c87cc84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # ShellKnight Changelog +## [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. +- **Scoring change, upward only:** a device whose password length could not be read gains the 20 points it was losing. No device loses points from this change. +- **Regression test:** `tests/Test-EngineScope.ps1` now also runs the CIS Benchmark block verbatim and asserts the CIS 1.1.1 finding. The engine-aborts and engine-disabled scenarios no longer expect the -20. New scenarios cover `net accounts` returning nothing, output with no 'Minimum password length' line, a length line with no number, and read lengths of 0, 6 and 10, so the rule still fires on a real value. + ## [v2026.09.25.001] - 2026-09-25 - **Assessment Engine results now reach the report and the score (critical):** `Invoke-SafeBlock` runs its block with `& $Block`, which is a child scope. The engine set `$avProduct`, `$edrProduct`, `$defStatus`, `$bitlockerWarn`, `$osEolWarn` and `$wuLastWarn` without a `$Script:` prefix, so each assignment made a local copy that was discarded when the block returned. The payload and the scoring read the script-level defaults instead, and have done since v1.002. **Every device reported `antivirus: "NONE DETECTED"`, `edr: "None detected"` and `defender: "Unknown"`**, and the BitLocker, OS end-of-life and Windows Update penalties never applied. `MachineInfo` and the log had the real values throughout. The payload now reads `antivirus`, `edr` and `defender` from `MachineInfo`, like the other machine fields, so they are null when the engine did not run instead of a default reported as fact. The three warn flags are `$Script:`-scoped. The three script-level defaults are gone: those values are now local to the engine, and `$avProduct` is assigned on every branch. diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index 7442e07..776eaaf 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -2,7 +2,7 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - ShellKnight v2026.09.25.001 - Enterprise Endpoint Security & Remediation Tool + ShellKnight v2026.09.25.002 - 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.001 + Version : v2026.09.25.002 Released : 2026-09-25 - Prior : v2026.09.24.001 + Prior : v2026.09.25.001 .ENGINES Phase 1 - Intel Engine : Threat intelligence download and cache @@ -33,6 +33,18 @@ Phase 8 - Reporting Engine : Reporting, trending, and extended checks .CHANGELOG + 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 + when the engine aborted or was disabled, or 'net accounts' gave + no 'Minimum password length' value, the scoring took 20 points + and the CIS block added the High finding "Password minimum + length is 0 (CIS 1.1.1)", which Battlefield alerts on and maps + to a VULN. That is a collection failure scored as a + vulnerability, which ADR 0009 rules out. It now starts at $null; + the scoring and CIS 1.1.1 skip it when $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. v2026.09.25.001 - Assessment Engine results now reach the report and the score. Invoke-SafeBlock runs its block as a child scope (& $Block). The engine set $avProduct, $edrProduct, $defStatus, @@ -458,7 +470,7 @@ param() # ============================================================================== -# SHELLKNIGHT v2026.09.25.001 CONFIGURATION +# SHELLKNIGHT v2026.09.25.002 CONFIGURATION # All settings are configured here. No external config files required. # Each engine can be independently enabled or disabled. # ============================================================================== @@ -651,7 +663,7 @@ try { # Runtime Config Object - single source of truth for all engines $Script:Config = [PSCustomObject]@{ - Version = 'v2026.09.25.001' + Version = 'v2026.09.25.002' # Intel Engine IntelEngine_Enabled = $SK_IntelEngine_Enabled IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates @@ -1041,7 +1053,7 @@ $Script:UseNewPSFeatures = $Script:PSVer -ge 5 # Banner $bannerWidth = 78 -$version = 'ShellKnight v2026.09.25.001' +$version = 'ShellKnight v2026.09.25.002' $hostname = $env:COMPUTERNAME $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $psver = "PS $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" @@ -1250,7 +1262,12 @@ $Script:WuLastWarn = $false $Script:HasActiveAv = $false $Script:AvDetectionRan = $false $inactiveAccounts = (New-Object 'System.Collections.Generic.List[object]') -$Script:MinPasswordLen = 0 +# $null until the engine's password check reads a length from 'net accounts'. +# Unknown is neither scored nor reported (ADR 0009). Until v2026.09.25.002 this +# started at 0, so an engine that aborted or was disabled, or a 'net accounts' +# with no 'Minimum password length' value, was scored -20 and reported as a High +# CIS 1.1.1 finding, which Battlefield alerts on. +$Script:MinPasswordLen = $null # Stable device identity - independent of hostname/site so Battlefield # can track a machine across renames and site moves. Prefer the hardware @@ -1555,13 +1572,16 @@ if ($Script:Config.AssessmentEngine_Enabled) { Invoke-SafeBlock -Label 'Password policy' -Block { $passOut = & net accounts 2>$null $minLenLine = $passOut | Where-Object { $_ -match 'Minimum password length' } - if ($minLenLine) { - $Script:MinPasswordLen = [int]($minLenLine -replace '[^\d]','') + # Set only from a number actually read: [int]'' is 0, and a length + # we could not read must stay $null (unknown), not become 0. + $minLenStr = "$minLenLine" -replace '[^\d]','' + if ($minLenStr) { + $Script:MinPasswordLen = [int]$minLenStr if ($Script:MinPasswordLen -eq 0) { Log-Warn "Password policy: minimum length is 0 - recommend 12 or more" } elseif ($Script:MinPasswordLen -lt 8) { Log-Warn "Password policy: minimum length is $Script:MinPasswordLen - recommend 12 or more" } elseif ($Script:MinPasswordLen -lt 12) { Log-Warn "Password policy: minimum length is $Script:MinPasswordLen - recommend 12 or more" } else { Log-Summary "Password policy: minimum length $Script:MinPasswordLen (OK)" } - } + } else { Log-Info "Password policy: minimum length unknown (no value from net accounts) - not scored" } } # Inactive accounts @@ -3215,8 +3235,11 @@ if ($Script:Config.ReportingEngine_Enabled) { $cisIssues = 0 Log-Info '--- CIS Benchmark Lite (Level 1) ---' - # 1.1.1 Password minimum length - if ($Script:MinPasswordLen -lt 8) { + # 1.1.1 Password minimum length. $null means the engine never read it, + # which is not a finding (ADR 0009). Note $null -lt 8 is $true. + if ($null -eq $Script:MinPasswordLen) { + Log-Info " [CIS 1.1.1] Password minimum length: unknown - not checked" + } elseif ($Script:MinPasswordLen -lt 8) { Log-Warn " [CIS 1.1.1] Password minimum length is $Script:MinPasswordLen - recommend 8+ (Level 1)" Add-Finding -Severity High -Title "Password minimum length is $Script:MinPasswordLen (CIS 1.1.1)" -Action 'Set MinimumPasswordLength >= 8 via domain GPO (local secedit is overridden on domain members)' $cisIssues++ @@ -3319,9 +3342,12 @@ try { $lmSc = (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Na if ($null -eq $lmSc -or $lmSc -lt 3) { $Script:SecurityScore -= 15 } } catch { } try { $fwSc = @(Get-NetFirewallProfile -ErrorAction Stop | Where-Object { $_.Enabled -eq $false }) if ($fwSc.Count -gt 0) { $Script:SecurityScore -= 15 } } catch { } -if ($Script:MinPasswordLen -eq 0) { $Script:SecurityScore -= 20 } -elseif ($Script:MinPasswordLen -lt 8){ $Script:SecurityScore -= 10 } -elseif ($Script:MinPasswordLen -lt 12){ $Script:SecurityScore -= 5 } +# Only a length the engine read is scored; $null (unknown) costs nothing. +if ($null -ne $Script:MinPasswordLen) { + if ($Script:MinPasswordLen -eq 0) { $Script:SecurityScore -= 20 } + elseif ($Script:MinPasswordLen -lt 8) { $Script:SecurityScore -= 10 } + elseif ($Script:MinPasswordLen -lt 12){ $Script:SecurityScore -= 5 } +} $Script:SecurityScore = [math]::Max(0, $Script:SecurityScore) # Performance Score @@ -3368,7 +3394,7 @@ $freeAfterGB = if ($diskAfter) { [math]::Round($diskAfter.FreeSpace / 1GB, 1) } $sepLine = '=' * 80 Log-Info $sepLine -Log-Info " ShellKnight v2026.09.25.001 - Report" +Log-Info " ShellKnight v2026.09.25.002 - Report" Log-Info " Hostname : $($env:COMPUTERNAME)" Log-Info " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Log-Info " Runtime : $runtime seconds" @@ -3381,7 +3407,7 @@ Log-Info $sepLine $bannerWidth2 = 78 Write-Host '' Write-Host " $sepLine" -ForegroundColor Cyan -Write-Host " ShellKnight v2026.09.25.001 - Report" -ForegroundColor Cyan +Write-Host " ShellKnight v2026.09.25.002 - 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 @@ -3653,7 +3679,7 @@ $jsonStamp= Get-Date -Format 'yyyy-MM-dd_HHmm' $jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json" $jsonData = [ordered]@{ - version = 'v2026.09.25.001' + version = 'v2026.09.25.002' device_id = $Script:DeviceId hardware_type = $Script:MachineInfo['Hardware Type'] site_name = $SK_SiteName diff --git a/tests/Test-EngineScope.ps1 b/tests/Test-EngineScope.ps1 index ad344bc..3d177a8 100644 --- a/tests/Test-EngineScope.ps1 +++ b/tests/Test-EngineScope.ps1 @@ -13,11 +13,19 @@ Update penalties never applied. MachineInfo, built inside the block, was right the whole time, which is why nobody noticed from the log. - This runs the whole of Phase 2, the security scoring, and the payload's - machine fields, all verbatim from ShellKnight.ps1. They run under the - script's own StrictMode 2 / SilentlyContinue settings, with the Windows - cmdlets replaced by mocks, so the test runs on the CI Linux runner. It does - not replace a real Windows run. + Up to v2026.09.25.001 the password minimum length also started at 0, and + only the engine's 'net accounts' check set it. An engine that aborted or + was disabled, or a 'net accounts' with no length, was therefore scored -20 + and reported as the High finding 'Password minimum length is 0 (CIS + 1.1.1)', which Battlefield alerts on. An unknown length must cost nothing + and raise nothing (ADR 0009). + + This runs the whole of Phase 2, the CIS Benchmark block, the security + scoring, and the payload's machine fields, all verbatim from + ShellKnight.ps1. They run under the script's own StrictMode 2 / + SilentlyContinue settings, with the Windows cmdlets replaced by mocks, so + the test runs on the CI Linux runner. It does not replace a real Windows + run. It also parses the whole script and fails on the general form of the bug: a variable assigned bare inside an Invoke-SafeBlock body and then read @@ -47,6 +55,8 @@ $biosDate = Get-Section '(?ms)^function ConvertTo-BiosDate \{.*?^\}' 'ConvertTo # 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)' +# The Reporting Engine's CIS block, which raises the CIS 1.1.1 finding. +$cis = Get-Section "(?ms)^ Invoke-SafeBlock -Label 'CIS Benchmark' -Block \{.*?^ \}" 'CIS Benchmark block' $scoring = Get-Section ('(?ms)^\$Script:SecurityScore = 100\s*$.*?' + '^\$Script:SecurityScore = \[math\]::Max\(0, \$Script:SecurityScore\)') 'Security scoring' # The payload's machine fields, evaluated as a hashtable of their own. @@ -151,7 +161,25 @@ function New-Object { # so the only deductions left are the ones under test. function Get-WindowsOptionalFeature { param([switch]$Online, $FeatureName, $ErrorAction) $null } function Get-LocalUser { param($Name, $ErrorAction) @() } -function net { 'Minimum password length 14' } +# 'net accounts' as English Windows prints it, with the scenario's length. +# Net 'empty': no output at all. 'no-length-line': the rest of it without the +# length line (a localized Windows prints no English 'Minimum password length'). +function net { + $s = $Script:S + if ($s.Net -eq 'empty') { return } + $out = @( + 'Force user logoff how long after time expires?: Never' + 'Minimum password age (days): 0' + 'Maximum password age (days): 42' + "Minimum password length: $($s.PwLen)" + 'Length of password history maintained: None' + 'Lockout threshold: Never' + 'Computer role: WORKSTATION' + 'The command completed successfully.' + ) + if ($s.Net -eq 'no-length-line') { $out = @($out | Where-Object { $_ -notmatch 'Minimum password length' }) } + $out +} function Get-SmbServerConfiguration { param($ErrorAction) [pscustomobject]@{ EnableSMB1Protocol = $false } } function Get-NetFirewallProfile { param($ErrorAction) @([pscustomobject]@{ Profile = 'Domain'; Enabled = $true }) } @@ -160,14 +188,15 @@ Invoke-Expression $biosDate # --- Scenarios -------------------------------------------------------------- # The machine each mock describes, and what the payload and the score must say. -# Penalty: points the four rules under test must take off 100 (AV -25, OS EOL -# -20, BitLocker -15, Windows Update -15). $null for Av/Edr/Def means the -# engine did not run, so the payload has no value to report. +# Penalty: points the five rules under test must take off 100 (AV -25, OS EOL +# -20, BitLocker -15, Windows Update -15, password length -20/-10/-5). $null +# 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. $healthy = @{ Engine = 'runs'; Caption = 'Microsoft Windows 11 Pro'; Build = '22631'; BitLocker = 'On'; WuDays = 6 - AvList = @('Windows Defender'); Defender = 'active'; Services = @() } + 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 } + $e = @{ Av = 'Windows Defender'; Edr = 'None detected'; Def = 'Active'; Penalty = 0; Finding = $false; Pw = $null } foreach ($k in $Expect.Keys) { $e[$k] = $Expect[$k] } $m.Name = $Name; $m.Expect = $e; $m } @@ -187,11 +216,20 @@ $scenarios = @( New-Scenario 'no-av' @{ AvList = @(); Defender = 'removed' } @{ Av = 'NONE DETECTED'; Def = 'Unknown'; Penalty = 25 } # Defender off and nothing else: -25 once, not -25 and -20. New-Scenario 'defender-off-no-av' @{ Defender = 'off' } @{ Av = 'Windows Defender (status DISABLED)'; Def = 'DISABLED'; Penalty = 25 } - # The engine produced nothing, so none of the four rules may fire. The 20 - # taken here is the password-length rule, which reads 0 when the engine's - # password check never ran. That is an existing flaw and not under test. - New-Scenario 'engine-aborts' @{ Engine = 'aborts'; BitLocker = 'Off'; WuDays = 45 } @{ Av = $null; Edr = $null; Def = $null; Penalty = 20 } - New-Scenario 'engine-disabled' @{ Engine = 'disabled'; BitLocker = 'Off'; WuDays = 45 } @{ Av = $null; Edr = $null; Def = $null; Penalty = 20 } + # A password length that was read is scored and reported as before. A real + # 0 keeps the exact title Battlefield maps to 'password-policy-blank'. + New-Scenario 'password-length-0' @{ PwLen = 0 } @{ Penalty = 20; Pw = 'Password minimum length is 0 (CIS 1.1.1)' } + New-Scenario 'password-length-6' @{ PwLen = 6 } @{ Penalty = 10; Pw = 'Password minimum length is 6 (CIS 1.1.1)' } + New-Scenario 'password-length-10' @{ PwLen = 10 } @{ Penalty = 5 } + # One that was not read is unknown: no penalty, no finding (ADR 0009). + New-Scenario 'net-accounts-empty' @{ Net = 'empty' } @{} + New-Scenario 'net-accounts-no-length' @{ Net = 'no-length-line' } @{} + # A length line with no number: [int]'' is 0, so this must not parse as 0. + New-Scenario 'net-accounts-no-number' @{ PwLen = '' } @{} + # The engine produced nothing, so none of the five rules may fire, though + # the machine has every problem they look for. + New-Scenario 'engine-aborts' @{ Engine = 'aborts'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null } + New-Scenario 'engine-disabled' @{ Engine = 'disabled'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null } ) $failures = 0 @@ -217,11 +255,14 @@ foreach ($sc in $scenarios) { $ErrorActionPreference = 'SilentlyContinue' # as ShellKnight.ps1 runs try { Invoke-Expression $phase2 + Invoke-Expression $cis Invoke-Expression $scoring $payload = Invoke-Expression $payloadSrc } finally { $ErrorActionPreference = 'Stop' } - $skipped = @($Script:Logged | Where-Object { $_ -match 'skipped' }) + # The CIS block may stop at a check after 1.1.1 whose mock throws (2.9, with + # Defender removed). Only 1.1.1 is under test; it is checked below. + $skipped = @($Script:Logged | Where-Object { $_ -match 'skipped' -and $_ -notmatch '^CIS Benchmark skipped' }) if ($sc.Engine -eq 'runs' -and $skipped.Count) { Fail $label "a block aborted: $($skipped -join ' | ')" } if ($sc.Engine -eq 'aborts' -and -not @($skipped | Where-Object { $_ -match '^Assessment Engine skipped' }).Count) { Fail $label 'expected the engine to abort in this scenario (test harness check)' @@ -247,8 +288,16 @@ foreach ($sc in $scenarios) { $blFinding = @($Script:Findings | Where-Object { $_.Title -like 'BitLocker not enabled*' }).Count -gt 0 if ($blFinding -ne $x.Finding) { Fail $label "BitLocker finding present: $blFinding, expected $($x.Finding)" } + $pwFindings = @($Script:Findings | Where-Object { $_.Title -like '*(CIS 1.1.1)' }) + # Without this, a CIS block that never reached 1.1.1 would pass as "no finding". + if (-not @($Script:Logged | Where-Object { $_ -match '\[CIS 1\.1\.1\]' }).Count -and -not $pwFindings.Count) { + Fail $label 'the CIS 1.1.1 check did not run (test harness check)' + } + $pw = if ($pwFindings.Count) { ($pwFindings | ForEach-Object { $_.Title }) -join ' | ' } else { $null } + if ($pw -ne $x.Pw -or ($null -eq $pw) -ne ($null -eq $x.Pw)) { Fail $label "CIS 1.1.1 finding = $(Show $pw), expected $(Show $x.Pw)" } + if ($failures -eq $before) { - Say " ok $label - score $($Script:SecurityScore); antivirus=$(Show $payload['antivirus']) edr=$(Show $payload['edr']) defender=$(Show $payload['defender'])" Green + Say " ok $label - score $($Script:SecurityScore); antivirus=$(Show $payload['antivirus']) edr=$(Show $payload['edr']) defender=$(Show $payload['defender']) password length=$(Show $Script:MinPasswordLen)" Green } } From e3ae14799e8eec4c429c29dda23dc32738abd5d4 Mon Sep 17 00:00:00 2001 From: cdburgess75 <508435+cdburgess75@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:23:38 -0500 Subject: [PATCH 2/2] Report password_min_length in the payload The minimum password length read from 'net accounts', as a JSON number, or null when it was not read. Without it Battlefield cannot tell "unknown" from "8 or more", because neither sends a CIS 1.1.1 finding. Battlefield stores the whole report as JSONB (ADR 0002) and requires only hostname, so it accepts the field unchanged; nothing displays it yet. Part of v2026.09.25.002, which has not shipped, so the version is not bumped again; both changelog entries are extended. tests/Test-EngineScope.ps1 asserts the field as serialized JSON in every scenario: the read length as a number, null when unknown. Removing the field, sending it as a string, or sending an unknown length as 0 each fails the test. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 ++- ShellKnight.ps1 | 4 ++++ tests/Test-EngineScope.ps1 | 38 +++++++++++++++++++++++--------------- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87cc84..9d48454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,8 @@ - **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. - **Scoring change, upward only:** a device whose password length could not be read gains the 20 points it was losing. No device loses points from this change. -- **Regression test:** `tests/Test-EngineScope.ps1` now also runs the CIS Benchmark block verbatim and asserts the CIS 1.1.1 finding. The engine-aborts and engine-disabled scenarios no longer expect the -20. New scenarios cover `net accounts` returning nothing, output with no 'Minimum password length' line, a length line with no number, and read lengths of 0, 6 and 10, so the rule still fires on a real value. +- **New payload field `password_min_length`:** the minimum password length read from `net accounts`, as a number, or `null` when it was not read. Without it Battlefield could not tell "unknown" from "8 or more", because neither sends a finding. Battlefield stores the whole report (ADR 0002), so it accepts the field unchanged; nothing displays it yet. +- **Regression test:** `tests/Test-EngineScope.ps1` now also runs the CIS Benchmark block verbatim and asserts the CIS 1.1.1 finding. It also asserts `password_min_length` in the payload as serialized JSON, so a read length is a number and an unknown one is `null`. The engine-aborts and engine-disabled scenarios no longer expect the -20. New scenarios cover `net accounts` returning nothing, output with no 'Minimum password length' line, a length line with no number, and read lengths of 0, 6 and 10, so the rule still fires on a real value. ## [v2026.09.25.001] - 2026-09-25 diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index 776eaaf..a25c013 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -45,6 +45,9 @@ the scoring and CIS 1.1.1 skip it when $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. + New payload field password_min_length: the length as a number, + or null when it was not read, so Battlefield can tell "unknown" + from "8 or more" (neither sends a finding). v2026.09.25.001 - Assessment Engine results now reach the report and the score. Invoke-SafeBlock runs its block as a child scope (& $Block). The engine set $avProduct, $edrProduct, $defStatus, @@ -3704,6 +3707,7 @@ $jsonData = [ordered]@{ defender = $Script:MachineInfo['Defender'] defender_sigs = $Script:MachineInfo['Defender Sigs'] last_wu_install = $Script:MachineInfo['Last WU Install'] + password_min_length = $Script:MinPasswordLen # null when not read, never a stand-in 0 domain = $Script:MachineInfo['Domain/Workgroup'] security_score = $Script:SecurityScore security_grade = $secGrade diff --git a/tests/Test-EngineScope.ps1 b/tests/Test-EngineScope.ps1 index 3d177a8..bb56981 100644 --- a/tests/Test-EngineScope.ps1 +++ b/tests/Test-EngineScope.ps1 @@ -18,7 +18,8 @@ was disabled, or a 'net accounts' with no length, was therefore scored -20 and reported as the High finding 'Password minimum length is 0 (CIS 1.1.1)', which Battlefield alerts on. An unknown length must cost nothing - and raise nothing (ADR 0009). + and raise nothing (ADR 0009), and the payload's password_min_length must + say null for it, not 0. This runs the whole of Phase 2, the CIS Benchmark block, the security scoring, and the payload's machine fields, all verbatim from @@ -60,8 +61,8 @@ $cis = Get-Section "(?ms)^ Invoke-SafeBlock -Label 'CIS Benchmark' -Blo $scoring = Get-Section ('(?ms)^\$Script:SecurityScore = 100\s*$.*?' + '^\$Script:SecurityScore = \[math\]::Max\(0, \$Script:SecurityScore\)') 'Security scoring' # The payload's machine fields, evaluated as a hashtable of their own. -$fields = [regex]::Matches($source, '(?m)^ (bitlocker|os_eol|antivirus|edr|defender)\s+=.*$') -if ($fields.Count -ne 5) { throw "expected 5 payload fields (bitlocker, os_eol, antivirus, edr, defender), found $($fields.Count)" } +$fields = [regex]::Matches($source, '(?m)^ (bitlocker|os_eol|antivirus|edr|defender|password_min_length)\s+=.*$') +if ($fields.Count -ne 6) { throw "expected 6 payload fields (bitlocker, os_eol, antivirus, edr, defender, password_min_length), found $($fields.Count)" } $payloadSrc = "[ordered]@{`n" + (($fields | ForEach-Object { $_.Value }) -join "`n") + "`n}" # --- Mocks. Functions take precedence over cmdlets of the same name. --------- @@ -191,12 +192,13 @@ Invoke-Expression $biosDate # Penalty: points the five rules under test must take off 100 (AV -25, OS EOL # -20, BitLocker -15, Windows Update -15, password length -20/-10/-5). $null # 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. +# 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 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 } + $e = @{ Av = 'Windows Defender'; Edr = 'None detected'; Def = 'Active'; Penalty = 0; Finding = $false; Pw = $null; Len = 14 } foreach ($k in $Expect.Keys) { $e[$k] = $Expect[$k] } $m.Name = $Name; $m.Expect = $e; $m } @@ -218,18 +220,19 @@ $scenarios = @( New-Scenario 'defender-off-no-av' @{ Defender = 'off' } @{ Av = 'Windows Defender (status DISABLED)'; Def = 'DISABLED'; Penalty = 25 } # A password length that was read is scored and reported as before. A real # 0 keeps the exact title Battlefield maps to 'password-policy-blank'. - New-Scenario 'password-length-0' @{ PwLen = 0 } @{ Penalty = 20; Pw = 'Password minimum length is 0 (CIS 1.1.1)' } - New-Scenario 'password-length-6' @{ PwLen = 6 } @{ Penalty = 10; Pw = 'Password minimum length is 6 (CIS 1.1.1)' } - New-Scenario 'password-length-10' @{ PwLen = 10 } @{ Penalty = 5 } - # One that was not read is unknown: no penalty, no finding (ADR 0009). - New-Scenario 'net-accounts-empty' @{ Net = 'empty' } @{} - New-Scenario 'net-accounts-no-length' @{ Net = 'no-length-line' } @{} + New-Scenario 'password-length-0' @{ PwLen = 0 } @{ Penalty = 20; Pw = 'Password minimum length is 0 (CIS 1.1.1)'; Len = 0 } + New-Scenario 'password-length-6' @{ PwLen = 6 } @{ Penalty = 10; Pw = 'Password minimum length is 6 (CIS 1.1.1)'; Len = 6 } + New-Scenario 'password-length-10' @{ PwLen = 10 } @{ Penalty = 5; Len = 10 } + # One that was not read is unknown: no penalty, no finding, null in the + # payload (ADR 0009). + New-Scenario 'net-accounts-empty' @{ Net = 'empty' } @{ Len = $null } + New-Scenario 'net-accounts-no-length' @{ Net = 'no-length-line' } @{ Len = $null } # A length line with no number: [int]'' is 0, so this must not parse as 0. - New-Scenario 'net-accounts-no-number' @{ PwLen = '' } @{} + New-Scenario 'net-accounts-no-number' @{ PwLen = '' } @{ Len = $null } # The engine produced nothing, so none of the five rules may fire, though # the machine has every problem they look for. - New-Scenario 'engine-aborts' @{ Engine = 'aborts'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null } - New-Scenario 'engine-disabled' @{ Engine = 'disabled'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null } + New-Scenario 'engine-aborts' @{ Engine = 'aborts'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null; Len = $null } + New-Scenario 'engine-disabled' @{ Engine = 'disabled'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null; Len = $null } ) $failures = 0 @@ -296,8 +299,13 @@ foreach ($sc in $scenarios) { $pw = if ($pwFindings.Count) { ($pwFindings | ForEach-Object { $_.Title }) -join ' | ' } else { $null } if ($pw -ne $x.Pw -or ($null -eq $pw) -ne ($null -eq $x.Pw)) { Fail $label "CIS 1.1.1 finding = $(Show $pw), expected $(Show $x.Pw)" } + # As Battlefield receives it: a JSON number when read, null when not. + $lenJson = @{ password_min_length = $payload['password_min_length'] } | ConvertTo-Json -Compress + $wantJson = if ($null -eq $x.Len) { '{"password_min_length":null}' } else { "{`"password_min_length`":$($x.Len)}" } + if ($lenJson -ne $wantJson) { Fail $label "payload $lenJson, expected $wantJson" } + if ($failures -eq $before) { - Say " ok $label - score $($Script:SecurityScore); antivirus=$(Show $payload['antivirus']) edr=$(Show $payload['edr']) defender=$(Show $payload['defender']) password length=$(Show $Script:MinPasswordLen)" Green + Say " ok $label - score $($Script:SecurityScore); antivirus=$(Show $payload['antivirus']) edr=$(Show $payload['edr']) defender=$(Show $payload['defender']) password_min_length=$(Show $payload['password_min_length'])" Green } }