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

## [v2026.09.26.001] - 2026-09-26

- **State directory hardened against local privilege escalation (critical):** `C:\ProgramData\ShellKnight` holds `config.json` (read at startup), `run.ps1` (the native `ShellKnight` scheduled task executes it as SYSTEM every 8 hours) and the `Logs`, `JSON` and `Intel` folders. By default ProgramData lets `BUILTIN\Users` create files and folders in its subfolders, and `CREATOR OWNER` gets full control of what they create. So on an endpoint where ShellKnight had never run, a standard user could pre-create `C:\ProgramData\ShellKnight` (or `run.ps1`, or `config.json`) and own it. Owning `run.ps1` lets them choose the code SYSTEM runs on the next scheduled run - a local privilege escalation. Owning `config.json` lets them set `BattlefieldURL` and `BattlefieldApiKey`, redirecting the run report and the tenant API key to a URL of their own. Once SYSTEM has created a file a user cannot modify it, but if the user owns the folder they can still delete and replace its files.
- **What changed:** before `config.json` is read or the scheduled task is trusted, a new guard (running during config load, ahead of `Initialize-Logging`) creates or repairs the folder with an explicit ACL and re-checks the sensitive files:
- **Explicit ACL, set by SID:** `SYSTEM` (`S-1-5-18`) and `Administrators` (`S-1-5-32-544`) full control, `Users` (`S-1-5-32-545`) read and execute, all inheritable to files and subfolders; owner set to Administrators; inheritance removed (`icacls /inheritance:r`) so ProgramData's `Users`-create ACEs no longer apply. SIDs are used throughout, never localized names, because the built-in groups are `Administratoren` / `Benutzer` on a German box but the SIDs are the same everywhere. Removing the parent's inherited ACEs and setting new inheritable ones propagates to existing `Logs` / `JSON` / `Intel` children that still inherit, so no recursive `/T` sweep runs on every check-in.
- **A folder a user already owns is rebuilt:** if the folder exists but `SYSTEM` or `Administrators` does not own it, it is removed with its contents and recreated. A user-owned tree has no run history worth keeping and may carry access-control entries the guard cannot enumerate.
- **`config.json` and `run.ps1` are owner-checked:** either one not owned by `SYSTEM` or `Administrators` is deleted, so a planted `config.json` is never read and a planted `run.ps1` is never executed. `run.ps1` is rewritten by the self-schedule block later in the run; a removed `config.json` means built-in defaults and environment variables stand in for that run.
- **Model:** owner is read with `(Get-Acl -LiteralPath ...).GetOwner([System.Security.Principal.SecurityIdentifier]).Value`, the same non-throwing pattern the Intel cache trust check uses (v2026.09.25.004); an owner that cannot be read is treated as untrusted. The whole guard is wrapped so a hardening failure logs a warning and never stops the run.
- **New payload fields:** the report's `health` object gains `state_dir_repaired` (the folder was found user-owned and rebuilt) and `state_dir_files_removed` (count of untrusted `config.json` / `run.ps1` deleted, 0/1/2), so Battlefield can flag an endpoint that showed signs of a local tampering attempt. Battlefield stores the whole report (ADR 0002), so it accepts the fields unchanged; nothing displays them yet.
- **Regression test:** new `tests/Test-StateDirGuard.ps1` runs the extracted guard verbatim under `Set-StrictMode -Version 2`, with `Get-Acl` and `icacls` mocked and real temp directories for the filesystem operations. It covers a fresh box (folder created), the steady state (trusted owner, folder and its contents kept), a user-owned folder (removed and recreated), an unreadable owner (treated as untrusted), and trusted vs. user-owned vs. unknown-owner `config.json` and `run.ps1`. It asserts the `icacls` arguments carry the three SIDs with the `*` prefix and no localized principal, that `/inheritance:r` and `/setowner` are issued, and - from the script's own source order - that the guard runs before the `config.json` read. It does not replace a real Windows run: `Get-Acl`, `icacls` and NTFS inheritance are Windows behaviours this test mocks.
- **Not yet run on real Windows.** The ACL, the ownership reclaim and the owner checks need one real SYSTEM run on a Windows 10/11 endpoint before this reaches `main`, per the repo rule.

## [v2026.09.25.004] - 2026-09-25

- **The Intel Engine loads threat intel for the first time (critical):** since v1.002 the engine's `Invoke-SafeBlock` read `$Script:Config.IntelEngine_PrimarySource`, which `$Script:Config` did not have; only `$SK_IntelEngine_PrimarySource` existed. Under `Set-StrictMode -Version 2` that threw in the `$consolidated` literal, before any download, cache write or `IntelSource`, and with no cache written the next run took the same path. **Every device on every run reported `intel_source: "Hardcoded fallback"` and 0 hash, filename and C2 IOCs** (Battlefield backtest, 2026-07-03 to 2026-09-25), so the detection engines ran on their hard-coded lists only. The only trace was one INFO line in the log: `Intel Engine skipped - The property 'IntelEngine_PrimarySource' cannot be found on this object. Verify that the property exists.` The property is now in `$Script:Config`.
Expand Down
210 changes: 200 additions & 10 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.004 - Enterprise Endpoint Security & Remediation Tool
ShellKnight v2026.09.26.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.004
Released : 2026-09-25
Prior : v2026.09.25.003
Version : v2026.09.26.001
Released : 2026-09-26
Prior : v2026.09.25.004

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

.CHANGELOG
v2026.09.26.001 - Harden C:\ProgramData\ShellKnight against local privilege
escalation. By default ProgramData lets BUILTIN\Users create files
and folders in its subfolders, and CREATOR OWNER gets full control
of what they create. On a box where ShellKnight had never run, a
standard user could pre-create C:\ProgramData\ShellKnight (or
run.ps1 / config.json) and own it, then choose the code the SYSTEM
scheduled task runs, or redirect the run report and its API key
through config.json. Now, before config.json is read or the task is
trusted, the folder is created or repaired with an explicit ACL -
SYSTEM and Administrators full control, Users read only, inheritance
removed so the ProgramData Users-create ACEs are gone - and a folder
a user already owns is removed and recreated. Any config.json or
run.ps1 not owned by SYSTEM or Administrators is deleted, not read
or executed. Owner is checked with (Get-Acl).GetOwner, the same
model as the Intel cache check (v2026.09.25.004); the ACL is set
with icacls by SID (S-1-5-18 / S-1-5-32-544 / S-1-5-32-545), never
by localized name. The report's health object gains
state_dir_repaired and state_dir_files_removed so the dashboard can
flag a box that showed signs of tampering. New tests/Test-
StateDirGuard.ps1. NOT yet run on real Windows - the ACL and owner
checks need one real SYSTEM run before this reaches main.
v2026.09.25.004 - The Intel Engine loads threat intel for the first time,
and every intel match is REPORT-ONLY. Since v1.002 the engine
read $Script:Config.IntelEngine_PrimarySource, which Config did
Expand Down Expand Up @@ -535,7 +556,7 @@


# ==============================================================================
# SHELLKNIGHT v2026.09.25.004 CONFIGURATION
# SHELLKNIGHT v2026.09.26.001 CONFIGURATION
# All settings are configured here. No external config files required.
# Each engine can be independently enabled or disabled.
# ==============================================================================
Expand Down Expand Up @@ -689,6 +710,162 @@
# engine can recognise (and never remove) our own persistence task.
$Script:SelfLauncherPath = Join-Path (Split-Path $Script:ConfigPath -Parent) 'run.ps1'

# ==============================================================================
# STATE DIRECTORY HARDENING - runs before anything on disk is trusted
# ==============================================================================
# C:\ProgramData\ShellKnight holds config.json (read just below), run.ps1 (the
# native scheduled task executes it as SYSTEM every 8 h) and the Logs, JSON and
# Intel folders. By default ProgramData lets BUILTIN\Users create files and
# folders in its subfolders, and CREATOR OWNER gets full control of what they
# create. So on a box where ShellKnight has never run, a standard user can
# pre-create this folder (or run.ps1 / config.json) and own it. Owning run.ps1
# lets them choose the code SYSTEM runs (local privilege escalation); owning
# config.json lets them point the run report and its API key at a URL of their
# own. Once SYSTEM has created a file a user cannot modify it, but if the user
# owns the folder they can still delete and replace its files.
#
# So, before the config below is read: create or repair the folder with an
# explicit ACL (SYSTEM and Administrators full control, Users read only, no
# inherited create rights), and delete any config.json or run.ps1 that SYSTEM
# or Administrators does not own. This mirrors the Intel cache owner check
# (v2026.09.25.004) and uses SIDs, never localized names, throughout - the
# groups are 'Administratoren' / 'Benutzer' on a German box but the SIDs are the
# same everywhere. It runs before Initialize-Logging, so it cannot Log-*; notes
# are buffered in $Script:StateGuardNotes and flushed to the log once it is open.
$Script:StateDir = Split-Path $Script:ConfigPath -Parent
$Script:StateGuardNotes = New-Object 'System.Collections.Generic.List[string]'
$Script:SID_System = 'S-1-5-18' # NT AUTHORITY\SYSTEM
$Script:SID_Administrators = 'S-1-5-32-544' # BUILTIN\Administrators
$Script:SID_Users = 'S-1-5-32-545' # BUILTIN\Users
$Script:TrustedOwnerSids = @($Script:SID_System, $Script:SID_Administrators)

function Add-StateGuardNote {
param([string]$Message)
$Script:StateGuardNotes.Add($Message)
Write-Host " [state-guard] $Message" -ForegroundColor Yellow
}

function Get-OwnerSid {
# The owning SID of a file or folder, or $null if the path does not exist or
# the owner cannot be read. A null owner is treated as untrusted by callers.
param([string]$Path)
if (-not (Test-Path -LiteralPath $Path)) { return $null }
try {
(Get-Acl -LiteralPath $Path -ErrorAction Stop).GetOwner(
[System.Security.Principal.SecurityIdentifier]).Value
} catch { $null }
}

function Test-TrustedOwner {
# $true only if the path exists and SYSTEM or Administrators owns it.
# ProgramData lets any local user create and own a file here, so any other
# owner - or one that cannot be read - is untrusted.
param([string]$Path)
(Get-OwnerSid -Path $Path) -in $Script:TrustedOwnerSids
}

function Invoke-Icacls {
# Thin wrapper so callers (and the test) drive icacls uniformly. Returns the
# exit code; 0 is success. Output is swallowed - the owner re-check and the
# run report are the record, not icacls' chatter.
param([Parameter(Mandatory = $true)][string[]]$Arguments)
$null = & icacls.exe @Arguments 2>&1
$LASTEXITCODE
}

function Set-StateDirAcl {
# Replace the folder's ACL with exactly SYSTEM and Administrators full
# control, Users read and execute, all inheritable to files and subfolders;
# owner Administrators; inheritance removed so ProgramData's Users-create
# ACEs are gone. Removing the parent's inherited ACEs and setting new
# inheritable ones propagates to existing Logs/JSON/Intel children that still
# inherit, so no /T sweep is needed on every run. SIDs take the '*' prefix
# icacls requires; -f formatting keeps the ':' out of string parsing.
param([string]$Path)
$sys = '*' + $Script:SID_System
$adm = '*' + $Script:SID_Administrators
$usr = '*' + $Script:SID_Users
$null = Invoke-Icacls @($Path, '/inheritance:r', '/Q', '/C')
$rc = Invoke-Icacls @($Path, '/grant:r',
('{0}:(OI)(CI)F' -f $sys),
('{0}:(OI)(CI)F' -f $adm),
('{0}:(OI)(CI)RX' -f $usr),
'/Q', '/C')
$null = Invoke-Icacls @($Path, '/setowner', $adm, '/Q', '/C')
$rc -eq 0
}

function Protect-StateDirectory {
# Create the state folder if missing; if it exists but SYSTEM or
# Administrators does not own it (a user pre-created it), remove it and its
# contents and recreate it - a user-owned tree has no run history worth
# keeping and may hold ACEs we cannot enumerate. Then stamp the explicit ACL
# either way. Returns a result object; never throws.
param([string]$Path)
$result = [ordered]@{
Existed = $false; OwnerSid = $null; OwnerTrusted = $false
Recreated = $false; Created = $false; AclApplied = $false
}
try {
$result.Existed = Test-Path -LiteralPath $Path
if ($result.Existed) {
$result.OwnerSid = Get-OwnerSid -Path $Path
$result.OwnerTrusted = $result.OwnerSid -in $Script:TrustedOwnerSids
if (-not $result.OwnerTrusted) {
$who = if ($result.OwnerSid) { $result.OwnerSid } else { 'an unknown account' }
Add-StateGuardNote "folder owned by $who, not SYSTEM or Administrators: removing and recreating it"
Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop
$result.Existed = $false
$result.Recreated = $true
}
}
if (-not $result.Existed) {
$null = New-Item -ItemType Directory -Path $Path -Force -ErrorAction Stop
$result.Created = $true
}
$result.AclApplied = Set-StateDirAcl -Path $Path
if (-not $result.AclApplied) {
Add-StateGuardNote "could not fully apply the hardened ACL to $Path (icacls returned non-zero)"
}
} catch {
Add-StateGuardNote "hardening error on $Path : $($_.Exception.Message)"
}
[pscustomobject]$result
}

function Remove-UntrustedStateFile {
# Delete a state file (config.json or run.ps1) unless SYSTEM or
# Administrators owns it. A user-owned config.json would redirect the report
# and its API key; a user-owned run.ps1 is code SYSTEM would execute. Once
# the folder ACL above is in place a user cannot create these, but on the
# first hardened run one may already be there. Returns $true if it removed
# one. The self-schedule block rewrites run.ps1; a removed config.json means
# built-in defaults and env vars stand in for this run.
param([string]$Path, [string]$Label)
if (-not (Test-Path -LiteralPath $Path)) { return $false }
if (Test-TrustedOwner -Path $Path) { return $false }
$owner = Get-OwnerSid -Path $Path
$who = if ($owner) { $owner } else { 'an unknown account' }
Add-StateGuardNote "$Label owned by $who, not SYSTEM or Administrators: deleting it"
Remove-Item -LiteralPath $Path -Force -ErrorAction SilentlyContinue
$true
}

# Harden the folder, then drop any pre-planted config.json / run.ps1 - all
# before the config below is read or the scheduled task (which runs run.ps1) is
# trusted. Guarded so a hardening failure never stops the run.
$Script:StateDirGuard = [pscustomobject]@{ Existed = $false; OwnerSid = $null; OwnerTrusted = $false; Recreated = $false; Created = $false; AclApplied = $false }
$Script:StateGuardFilesRemoved = 0
try {
$Script:StateDirGuard = Protect-StateDirectory -Path $Script:StateDir
$Script:StateGuardFilesRemoved = 0
if (Remove-UntrustedStateFile -Path $Script:ConfigPath -Label 'config.json') { $Script:StateGuardFilesRemoved++ }
if (Remove-UntrustedStateFile -Path $Script:SelfLauncherPath -Label 'run.ps1') { $Script:StateGuardFilesRemoved++ }
} catch {
$Script:StateGuardFilesRemoved = 0
Add-StateGuardNote "state hardening skipped: $($_.Exception.Message)"
}

# Load persisted config FIRST (scheduled runs rely on this); env overrides win after.
if (Test-Path $Script:ConfigPath) {
try {
Expand All @@ -698,7 +875,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 878 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 @@ -727,11 +904,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 907 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.004'
Version = 'v2026.09.26.001'
# Intel Engine
IntelEngine_Enabled = $SK_IntelEngine_Enabled
IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates
Expand Down Expand Up @@ -1035,7 +1212,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 1215 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 @@ -1158,7 +1335,7 @@
}
}
}
} catch { } # denied dir: skip it, continue with the rest of the stack

Check warning on line 1338 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 @@ -1175,7 +1352,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 1355 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 @@ -1320,12 +1497,12 @@
if ((Get-Item -LiteralPath $File -Force -ErrorAction Stop).Length -le 100MB) {
$sha = (Get-FileHash -LiteralPath $File -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant()
}
} catch { }

Check warning on line 1500 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.
try {
$sig = Get-AuthenticodeSignature -LiteralPath $File -ErrorAction Stop
$sigStatus = "$($sig.Status)"
if ($sig.SignerCertificate) { $signer = $sig.SignerCertificate.Subject }
} catch { }

Check warning on line 1505 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.
}
$Script:IntelMatches.Add([ordered]@{
kind = $Kind; source = $Source; target = $Target; indicator = $Indicator; score = $Score
Expand All @@ -1348,12 +1525,20 @@
# ==============================================================================
Initialize-Logging

# Flush state-directory hardening notes captured before the log was open (the
# guard runs during config load, ahead of Initialize-Logging).
if ($Script:StateGuardNotes.Count) {
foreach ($n in $Script:StateGuardNotes) { Log-Warn "State directory - $n" }
} else {
Log-Info "State directory - $Script:StateDir owner and ACL verified"
}

# Detect PS version compatibility
$Script:UseNewPSFeatures = $Script:PSVer -ge 5

# Banner
$bannerWidth = 78
$version = 'ShellKnight v2026.09.25.004'
$version = 'ShellKnight v2026.09.26.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 @@ -1385,6 +1570,11 @@
self_scheduled = [bool]$SK_SelfSchedule
schedule_hours = $SK_ScheduleHours
next_run = $null
# State-directory hardening outcome (captured at config load). Lets the
# dashboard flag a box where a user had pre-created the folder or planted a
# config.json / run.ps1 - a sign of an attempted local privilege escalation.
state_dir_repaired = [bool]$Script:StateDirGuard.Recreated
state_dir_files_removed = $Script:StateGuardFilesRemoved
}
try {
$existingTask = Get-ScheduledTask -TaskName 'ShellKnight' -ErrorAction Stop
Expand Down Expand Up @@ -1428,7 +1618,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 1621 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 @@ -1699,7 +1889,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 1892 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 @@ -1747,7 +1937,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 1940 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 Down Expand Up @@ -3840,7 +4030,7 @@
$sepLine = '=' * 80

Log-Info $sepLine
Log-Info " ShellKnight v2026.09.25.004 - Report"
Log-Info " ShellKnight v2026.09.26.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 @@ -3853,7 +4043,7 @@
$bannerWidth2 = 78
Write-Host ''
Write-Host " $sepLine" -ForegroundColor Cyan
Write-Host " ShellKnight v2026.09.25.004 - Report" -ForegroundColor Cyan
Write-Host " ShellKnight v2026.09.26.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 @@ -4129,7 +4319,7 @@
$jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json"

$jsonData = [ordered]@{
version = 'v2026.09.25.004'
version = 'v2026.09.26.001'
device_id = $Script:DeviceId
hardware_type = $Script:MachineInfo['Hardware Type']
site_name = $SK_SiteName
Expand Down
Loading
Loading