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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
66 changes: 48 additions & 18 deletions ShellKnight.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
#Requires -RunAsAdministrator
<#
.SYNOPSIS
ShellKnight v2026.09.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
Expand All @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -458,7 +473,7 @@


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

Check warning on line 636 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 636 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

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

Check warning on line 665 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 665 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

# Runtime Config Object - single source of truth for all engines
$Script:Config = [PSCustomObject]@{
Version = 'v2026.09.25.001'
Version = 'v2026.09.25.002'
# Intel Engine
IntelEngine_Enabled = $SK_IntelEngine_Enabled
IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates
Expand Down Expand Up @@ -934,7 +949,7 @@
# ParseExact rejects against 'yyyyMMdd', so the legacy path was broken too.)
$s = [string]$ReleaseDate
if ($s.Length -ge 8) {
try { return [datetime]::ParseExact($s.Substring(0, 8), 'yyyyMMdd', $null) } catch { }

Check warning on line 952 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 952 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1011 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1011 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1028 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1028 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

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

Check warning on line 1134 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1134 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
Log-Info "Self-schedule ensured: every $SK_ScheduleHours h at :$startTime (SYSTEM)"
} catch { Log-Warn "Self-schedule failed: $($_.Exception.Message)" }
}
Expand Down Expand Up @@ -1250,7 +1265,12 @@
$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
Expand All @@ -1274,7 +1294,7 @@
try { $null = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop } catch { $wmiUp = $false }
}
if (-not $deviceId -and $wmiUp) {
try { $deviceId = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid } catch { }

Check warning on line 1297 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1297 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
}
if ($deviceId) { $Script:DeviceId = $deviceId }
}
Expand Down Expand Up @@ -1322,7 +1342,7 @@
$blWmi = Get-CimInstance -Namespace 'Root\CIMV2\Security\MicrosoftVolumeEncryption' `
-ClassName 'Win32_EncryptableVolume' -Filter "DriveLetter='C:'" -ErrorAction Stop
$blStatus = if ($blWmi.ProtectionStatus -eq 1) { 'On' } else { 'Off'; $Script:BitLockerWarn = $true }
} catch { }

Check warning on line 1345 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1345 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

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

Check warning on line 1384 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1384 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

# Datto AV (registered AV product; RMM handled elsewhere)
if (Get-Service -Name 'EndpointProtectionService2' -ErrorAction SilentlyContinue) {
Expand Down Expand Up @@ -1433,7 +1453,7 @@
try {
$disableRtp = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection' `
-Name 'DisableRealtimeMonitoring' -ErrorAction Stop).DisableRealtimeMonitoring
} catch { }

Check warning on line 1456 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

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

Check warning on line 1456 in ShellKnight.ps1

View workflow job for this annotation

GitHub Actions / validate

[PSAvoidUsingEmptyCatchBlock] Empty catch block is used. Please use Write-Error or throw statements in catch blocks.
$defRtp = ($wd.Status -eq 'Running' -and $disableRtp -ne 1)
}
}
Expand Down Expand Up @@ -1555,13 +1575,16 @@
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
Expand Down Expand Up @@ -3215,8 +3238,11 @@
$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++
Expand Down Expand Up @@ -3319,9 +3345,12 @@
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
Expand Down Expand Up @@ -3368,7 +3397,7 @@
$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"
Expand All @@ -3381,7 +3410,7 @@
$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
Expand Down Expand Up @@ -3653,7 +3682,7 @@
$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
Expand All @@ -3678,6 +3707,7 @@
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
Expand Down
Loading
Loading