diff --git a/CHANGELOG.md b/CHANGELOG.md index cd35cc0..9d48454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # 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. +- **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 - **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..a25c013 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,21 @@ 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. + 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, @@ -458,7 +473,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 +666,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 +1056,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 +1265,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 +1575,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 +3238,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 +3345,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 +3397,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 +3410,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 +3682,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 @@ -3678,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 ad344bc..bb56981 100644 --- a/tests/Test-EngineScope.ps1 +++ b/tests/Test-EngineScope.ps1 @@ -13,11 +13,20 @@ 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), 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 + 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,11 +56,13 @@ $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. -$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. --------- @@ -151,7 +162,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 +189,16 @@ 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. 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 = @() } + 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; Len = 14 } foreach ($k in $Expect.Keys) { $e[$k] = $Expect[$k] } $m.Name = $Name; $m.Expect = $e; $m } @@ -187,11 +218,21 @@ $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)'; 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 = '' } @{ 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; Len = $null } + New-Scenario 'engine-disabled' @{ Engine = 'disabled'; BitLocker = 'Off'; WuDays = 45; PwLen = 0 } @{ Av = $null; Edr = $null; Def = $null; Len = $null } ) $failures = 0 @@ -217,11 +258,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 +291,21 @@ 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)" } + + # 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'])" 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 } }