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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# ShellKnight Changelog

## [v2026.10.13.001] - 2026-10-13

- **Windows Admin Center's tasks on Azure VMs are no longer removed:** the Process Engine removes any scheduled task whose command line matches its obfuscation pattern (`encodedcommand|...|hidden|noprofile|...`) unless the command is in `LegitTaskPaths`. Windows Admin Center's Azure VM extension runs its `WindowsAdminCenterHeartbeatCheck` and `WindowsAdminCenterTroubleshootTask` tasks as `Powershell.exe -WindowStyle Hidden -File "C:\Packages\Plugins\Microsoft.AdminCenter.AdminCenter\<version>\Sme.VmExtension\..."`, so the first run on an Azure Virtual Desktop session host deleted both and reported them as High IOCs. Commands run from `\Packages\Plugins\Microsoft.AdminCenter.AdminCenter\` are now in `LegitTaskPaths`. The folder belongs to the Azure guest agent and is writable only by administrators. The same flags from any other folder, including other extensions under `C:\Packages\Plugins\`, are still removed. Restoring the deleted tasks means repairing the Windows Admin Center extension from the Azure portal.
- **Regression test:** new `tests/Test-LegitTasks.ps1` takes `LegitTaskPaths` and the obfuscation pattern from the script and checks that the real Windows Admin Center command lines are kept, that a later extension version is kept, and that hidden or encoded PowerShell anywhere else is still removed. It fails against v2026.09.25.003 and passes here.

## [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.
Expand Down
35 changes: 24 additions & 11 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.003 - Enterprise Endpoint Security & Remediation Tool
ShellKnight v2026.10.13.001 - 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.003
Released : 2026-09-25
Prior : v2026.09.25.002
Version : v2026.10.13.001
Released : 2026-10-13
Prior : v2026.09.25.003

.ENGINES
Phase 1 - Intel Engine : Threat intelligence download and cache
Expand All @@ -33,6 +33,15 @@
Phase 8 - Reporting Engine : Reporting, trending, and extended checks

.CHANGELOG
v2026.10.13.001 - Windows Admin Center's tasks on Azure VMs are no longer
removed. They run PowerShell with -WindowStyle Hidden from the
Azure extension folder, which the scheduled-task check matched as
obfuscation, so the Process Engine deleted
WindowsAdminCenterHeartbeatCheck and
WindowsAdminCenterTroubleshootTask on its first run. Commands run
from C:\Packages\Plugins\Microsoft.AdminCenter.AdminCenter\ are
now in LegitTaskPaths.

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)
Expand Down Expand Up @@ -497,7 +506,7 @@


# ==============================================================================
# SHELLKNIGHT v2026.09.25.003 CONFIGURATION
# SHELLKNIGHT v2026.10.13.001 CONFIGURATION
# All settings are configured here. No external config files required.
# Each engine can be independently enabled or disabled.
# ==============================================================================
Expand Down Expand Up @@ -657,7 +666,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 669 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 669 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 @@ -686,11 +695,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 698 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 698 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.003'
Version = 'v2026.10.13.001'
# Intel Engine
IntelEngine_Enabled = $SK_IntelEngine_Enabled
IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates
Expand Down Expand Up @@ -800,7 +809,11 @@

$Script:LegitTaskPaths = @(
'*AppData\Roaming\Zoom\bin\*','*Teams\*','*Slack\*','*Spotify\*',
'*Discord\*','*Cricut*','*AppData\Roaming\PCDr\*','*BundleApplicationRepairTool.exe*'
'*Discord\*','*Cricut*','*AppData\Roaming\PCDr\*','*BundleApplicationRepairTool.exe*',
# Windows Admin Center's Azure VM extension. Its heartbeat and troubleshoot
# tasks run PowerShell -WindowStyle Hidden, which the obfuscation check
# matches. The folder is the Azure guest agent's, writable only by admins.
'*\Packages\Plugins\Microsoft.AdminCenter.AdminCenter\*'
)

# Task Scheduler path prefixes for known-legitimate Microsoft system tasks.
Expand Down Expand Up @@ -973,7 +986,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 989 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 989 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 @@ -1096,7 +1109,7 @@
}
}
}
} catch { } # denied dir: skip it, continue with the rest of the stack

Check warning on line 1112 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 1112 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 @@ -1113,7 +1126,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 1129 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 1129 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 @@ -1144,7 +1157,7 @@

# Banner
$bannerWidth = 78
$version = 'ShellKnight v2026.09.25.003'
$version = 'ShellKnight v2026.10.13.001'
$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 @@ -1219,7 +1232,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 1235 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 1235 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 @@ -1382,7 +1395,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 1398 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 1398 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 @@ -1430,7 +1443,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 1446 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 1446 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: Microsoft's date for this build and edition
Expand All @@ -1456,7 +1469,7 @@
if ($avName -match 'Windows Defender|Microsoft Defender') { $defenderRegistered = $true }
else { $avProducts.Add($avName) }
}
} catch { }

Check warning on line 1472 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 1472 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 @@ -1528,7 +1541,7 @@
try {
$disableRtp = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows Defender\Real-Time Protection' `
-Name 'DisableRealtimeMonitoring' -ErrorAction Stop).DisableRealtimeMonitoring
} catch { }

Check warning on line 1544 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 1544 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 @@ -3472,7 +3485,7 @@
$sepLine = '=' * 80

Log-Info $sepLine
Log-Info " ShellKnight v2026.09.25.003 - Report"
Log-Info " ShellKnight v2026.10.13.001 - Report"
Log-Info " Hostname : $($env:COMPUTERNAME)"
Log-Info " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
Log-Info " Runtime : $runtime seconds"
Expand All @@ -3485,7 +3498,7 @@
$bannerWidth2 = 78
Write-Host ''
Write-Host " $sepLine" -ForegroundColor Cyan
Write-Host " ShellKnight v2026.09.25.003 - Report" -ForegroundColor Cyan
Write-Host " ShellKnight v2026.10.13.001 - Report" -ForegroundColor Cyan
Write-Host " Hostname : $($env:COMPUTERNAME)" -ForegroundColor White
Write-Host " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor White
Write-Host " Runtime : $runtime seconds" -ForegroundColor White
Expand Down Expand Up @@ -3757,7 +3770,7 @@
$jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json"

$jsonData = [ordered]@{
version = 'v2026.09.25.003'
version = 'v2026.10.13.001'
device_id = $Script:DeviceId
hardware_type = $Script:MachineInfo['Hardware Type']
site_name = $SK_SiteName
Expand Down
66 changes: 66 additions & 0 deletions tests/Test-LegitTasks.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<#
.SYNOPSIS
Regression test for the scheduled-task allow-list.

.DESCRIPTION
The Process Engine removes any scheduled task whose command line matches
its obfuscation pattern unless the command is in LegitTaskPaths. On an
Azure VM that removed Windows Admin Center's heartbeat and troubleshoot
tasks, which run PowerShell -WindowStyle Hidden from the Azure extension
folder. This checks those commands are allowed and that the allow-list
entry is narrow: the same flags from anywhere else are still caught.

ShellKnight.ps1 executes on load, so the list and the pattern are
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

$list = [regex]::Match($source, '(?ms)^\$Script:LegitTaskPaths = @\(.*?^\)')
if (-not $list.Success) { throw 'LegitTaskPaths not found in ShellKnight.ps1' }
Invoke-Expression $list.Value

$pat = [regex]::Match($source, "(?m)^\s*'(encodedcommand\|[^']+)',\s*$")
if (-not $pat.Success) { throw 'Scheduled-task obfuscation pattern not found in ShellKnight.ps1' }
$obfusc = New-Object System.Text.RegularExpressions.Regex -ArgumentList @(
$pat.Groups[1].Value, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)

function Test-Removed([string]$CmdLine) {
$isLegit = $Script:LegitTaskPaths | Where-Object { $CmdLine -like $_ }
return [bool]($obfusc.IsMatch($CmdLine) -and -not $isLegit)
}

$failures = 0
function Assert-Removed {
param([string]$CmdLine, [bool]$Expected, [string]$Label)
$actual = Test-Removed $CmdLine
if ($actual -eq $Expected) {
Write-Host " PASS $Label"
} else {
Write-Host " FAIL $Label - removed=$actual, expected $Expected" -ForegroundColor Red
$script:failures++
}
}

# Real command lines from an Azure Virtual Desktop session host (2026-10-13).
$wac = 'C:\Packages\Plugins\Microsoft.AdminCenter.AdminCenter\0.76.0.0\Sme.VmExtension'
Assert-Removed "Powershell.exe -WindowStyle Hidden -File `"$wac\HeartbeatCheck.ps1`"" $false 'WAC heartbeat task is kept'
Assert-Removed "Powershell.exe -WindowStyle Hidden -File `"$wac\Troubleshoot.ps1`"" $false 'WAC troubleshoot task is kept'
Assert-Removed "powershell.exe -WindowStyle Hidden -File `"C:\Packages\Plugins\Microsoft.AdminCenter.AdminCenter\0.80.0.0\x.ps1`"" $false 'a later WAC version is kept'

# The same flags from anywhere else are still caught.
Assert-Removed 'powershell.exe -WindowStyle Hidden -File "C:\Users\Public\x.ps1"' $true 'hidden PowerShell elsewhere is removed'
Assert-Removed 'powershell.exe -WindowStyle Hidden -File "C:\Packages\Plugins\Other.Vendor\x.ps1"' $true 'another extension folder is not allowed'
Assert-Removed 'powershell.exe -enc SQBFAFgA' $true 'encoded command is removed'
Assert-Removed 'C:\Windows\System32\cleanmgr.exe /autoclean' $false 'ordinary task is left alone'

Write-Host ''
if ($failures -gt 0) {
Write-Host " FAILED - $failures assertion(s)" -ForegroundColor Red
exit 1
}
Write-Host ' PASS - all assertions' -ForegroundColor Green
exit 0
Loading