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

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

- **A value that is not set no longer stops a check:** a real run on HOST-A3 (Windows 11 Pro 22621, v2026.09.25.003, 2026-09-26) logged eight `Invoke-SafeBlock` skips, and each one dropped the rest of its block. Most checks read a registry value as `(Get-ItemProperty $key -Name X -ErrorAction SilentlyContinue).X`. Where X is not set, which is Windows' default for most policies, `Get-ItemProperty` returns nothing, and `.X` on nothing throws under `Set-StrictMode -Version 2`: `The property 'X' cannot be found on this object`. StrictMode has been on since v1.002. So on any box where these values are not set, the LLMNR check, the LAN Manager auth check, the PowerShell script block audit and the credential exposure check have never run to the end, and the CIS Benchmark block stopped after 1.1.1. The new `Get-RegistryValue` returns the value, or `$null` when the key or value is absent or unreadable, and never throws. Every read of that form in the script now uses it. That includes the RDP/NLA check, which did not fail on this box but reads its values the same way. A new static test fails on any new one.
- **What a value that is not set means (ADR 0009):** it means Windows' documented default where there is one, and unknown otherwise. Neither is raised as a finding or scored.
- `LmCompatibilityLevel` not set is Windows' default, level 3 (send NTLMv2 responses only; Windows 7 / Server 2008 R2 and later, per Microsoft's Policy CSP). The Hardening Engine logs it as OK and CIS 2.3 passes. `$SK_SetLMAuthLevel` still raises only a level that is set below 3, as it always has for 3 and 4.
- `EnableMulticast` not set: LLMNR is on, which is Windows' default. That is a warning, as the check always meant; it writes only when `$SK_DisableLLMNR` is on.
- `UseLogonCredential` not set: WDigest keeps no plaintext credentials (Windows 8.1 / Server 2012 R2 and later), so it is not an IOC. `RunAsPPL` and `EnableVirtualizationBasedSecurity` not set: off, and logged as before.
- `NoDriveTypeAutoRun` not set: AutoRun is not fully disabled, a CIS 2.8 issue in the log (not scored).
- `fDenyTSConnections` or `UserAuthentication` not found: RDP is logged as not checked. Before, a missing value stopped the block; with the new read, it would have become the Medium finding "RDP enabled, NLA not enforced" from a value never read.
- **Script block logging is opt-in (fleet safety):** the audit created the `ScriptBlockLogging` policy key and set `EnableScriptBlockLogging = 1` whenever the value was not 1, with no config switch. That included overwriting an explicit 0 set by GPO. The read in front of the write threw wherever the policy was not set, so the write has never run on those boxes. Fixing the read alone would have turned event 4104 logging on across the fleet on the first run of this version. Script block logging records the text of every script block run, which can include secrets, so it is the customer's call. The write now needs the new `$SK_EnableScriptBlockLogging`, which defaults to `$false`, like every other auto-remediation. Without it, the audit logs that logging is off and recommends enabling it via GPO. Where logging is on, the 4104 audit runs as before.
- **Windows Update cache, and Windows Update left stopped:** `Remove-FolderContents` summed sizes with `(... | Measure-Object -Property Length -Sum).Sum`. Over no files, `Measure-Object -Property` outputs nothing, and `.Sum` threw (`Windows Update Cache skipped - The property 'Sum' cannot be found`). It now returns early for an empty folder, which also covers the other cleanups that call it (Delivery Optimization, Prefetch). The Windows Update block stopped `wuauserv`, `bits` and `UsoSvc` before the cleanup and started them again after it. So an empty cache left the three services stopped until something else started them. The restart is now in a `finally`, so the services are started again whatever the cleanup does.
- **Local Administrators:** `Get-LocalGroupMember` fails for the whole group when it cannot resolve one member: an orphaned domain SID, an Entra ID member, or a domain it cannot reach. On HOST-A3 it failed with `An unspecified error occurred: error code = 1789`. The new `Get-LocalAdminName` asks `Get-LocalGroupMember -SID S-1-5-32-544` first, so a localized group name such as `Administratoren` also works. If that fails, it falls back to the WinNT provider (ADSI), which lists members without resolving them; a member that cannot be resolved comes back as its SID. If neither works, the check logs that the admins could not be listed and raises nothing. **Expect new findings** on boxes where this check was being skipped: `Local admin: ...` (Medium) for each unexpected member, orphaned SIDs included, and `'...\Domain Users' is in local Administrators` (High). These are the findings any box that could list its admins has always raised.
- **Defender exclusions:** `Get-MpPreference` fails where Defender is off because another AV owns the box (`Operation failed with the following error: 0x%1!x!`, on HOST-A3 with Datto AV). The block now logs `Defender exclusions - not checked` and raises nothing, since the exclusions are unknown. An object with no `ExclusionPath` is read as none.
- **Antivirus names each product once:** HOST-A3 reported `antivirus: "Datto AV, Datto AV"`. SecurityCenter2 can list a product more than once, and the Datto service check (`EndpointProtectionService2`) added Datto AV again even when SecurityCenter2 already had it. Each name is now added once, compared case-insensitively. The score is unchanged: it depends only on whether an AV is active.
- **Scoring:** no rule changes, and no device's score changes. The LAN Manager rule is rewritten as "a level that is set and below 3 costs 15". Its old `$null -eq $lmSc` branch could never fire, because the read used `-ErrorAction Stop` and a value that was not set went to the catch. Findings can change: see Local Administrators.
- **Regression tests:** the new `tests/Test-MissingRegistryValues.ps1` runs the RDP, LLMNR, LAN Manager, local admin, Defender exclusion, Windows Update cache, script block audit, credential exposure and CIS Benchmark blocks and the LAN Manager scoring rule verbatim under StrictMode 2. Its registry mock returns the key without the value asked for (a registry object missing that property), no key at all, or a key that cannot be read. It asserts that each block runs to its end, what it logs, every registry write, every finding, the IOC count and the score. It runs the Windows Update block against a real temp folder (empty, with files, and with a cleanup that throws) and asserts the service stop/start order. It also covers the fallback from `Get-LocalGroupMember` error 1789 to ADSI, and checks that `$SK_EnableScriptBlockLogging` defaults to `$false`. A static AST check fails on any property read straight off `(Get-ItemProperty ...)` unless it is `-ErrorAction Stop` inside a `try`. With the old read pattern put back in `Get-RegistryValue`, the test fails 38 assertions and reproduces the HOST-A3 messages word for word. Each of 12 other fixes, reverted on its own, fails at least one assertion in this test or `Test-EngineScope.ps1`. `tests/Test-EngineScope.ps1` loads `Get-RegistryValue` and adds three scenarios: `lm-not-set` (no deduction), `lm-2` (-15) and `av-duplicates`. It also now fails if the CIS block stops anywhere except its last check with Defender removed.
- **Not changed:** the `Intel Engine skipped - ...IntelEngine_PrimarySource` line from the same run, as decided.
- **Not yet run on real Windows.** Per the repo rule, this needs one real SYSTEM run before it reaches `main`. `Get-LocalGroupMember`, the ADSI fallback, `Get-MpPreference` with Defender off, and the registry reads are Windows behaviours the tests mock.

## [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.
Expand Down
Loading
Loading