diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5841a..1febeaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # ShellKnight Changelog +## [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`. +- **The parser keeps what can match:** it kept each whole trimmed line, so a hash entry was `hash;comment` and a filename entry `regex;score`. No computed hash or file name could ever equal one, so hash and filename intel could not have matched even with the Config fix. The new `ConvertFrom-IntelFeed` follows each file's own header. From `hash-iocs.txt` it keeps the SHA256; the MD5s and SHA1s are dropped, because the scan computes SHA256 only. From `c2-iocs.txt` it keeps the domain or IPv4. From `filename-iocs.txt` it keeps `regex;score[;false-positive regex]` and drops the Unix paths. A line that does not fit is dropped, never guessed at. It trims each line before testing it: a CRLF file's blank lines are `"\r"`, and the old code would have turned them into an empty entry that matched every Run value and every hosts line. On the September 2026 lists it keeps 3,707 Windows filename patterns, 1,260 SHA256s and 1,863 C2 entries. +- **Filename IOCs are regexes over full paths:** `Find-IntelFilenameMatch` applies them as LOKI does: a case-sensitive regex searched for in the full path, unless the entry's false-positive regex also matches. Before, the consumers compared them with exact names (`Contains($proc.Name)`) or as escaped literal substrings. The regexes are compiled once, with a 250 ms match timeout. Only entries scored 60 or more load (`$SK_IntelEngine_MinFilenameScore`), LOKI's warning level; below it a match is a LOKI "notice". That is 2,184 of the 3,707. +- **C2 matching by whole labels:** `Find-IntelC2Match` matches a listed domain and its subdomains (`x.evil.example` for `evil.example`, as LOKI's substring test does), and an address only exactly. The hosts file check used unanchored substrings, so `earn.fm` would have matched `learn.fm`. It now checks each address and name on a line, ignoring comments. A C2 name pointed at `0.0.0.0`, loopback or `::`/`::1` is a block that a blocklist added, and is logged as such. The DNS cache check also checks what a name resolved to (a C2 address, or a CNAME to a C2 name). +- **Every intel match is report-only (fleet safety):** intel has never loaded in the field. With the parser fixed and nothing else changed, a feed match would have killed a process outside Windows and Program Files, removed a Run value, deleted a startup shortcut, or deleted a file in a redirected folder. Each would also have been an IOC: -15 points, exit code 2, and a Critical alert in Battlefield. Even the Config fix alone would have raised IOCs, from C2 substring matches in the hosts file. Every intel match now goes through `Add-IntelHit`, which logs and counts it. The first 50 go into the new payload object `intel.matches`, each with its source (process, Run value, startup shortcut, redirected folder, scanned file, hosts file, DNS cache), target, indicator, score, what a hard-coded match there would do (`would_have`), and, for a file, its SHA256 and Authenticode signer. The first 20 per run become Low findings titled `Intel match (report-only): ...`. None of it is an IOC: not in `ioc_alerts`, the score or the exit code, and no Battlefield alert (Low severity, and the title does not start with "IOC"). Nothing is killed, stopped or deleted. Matches against the hard-coded lists act exactly as before. Intel matches stay report-only until a release's worth of `intel.matches` has been reviewed. Against the September 2026 lists, 3 of a hand-picked 52 common Windows paths match at score 60 or more: `\\tmp\.exe;60`, `\\new\.exe;60` and `\\k7sysmon\.exe;60` (the name of a K7 antivirus component). +- **Guards against a bad upstream list:** the feed is a third-party GitHub repository, and one bad line would reach every device. + - A list over 5 MB, or with under 100 or over 20,000 usable entries, is treated as an error page or the wrong file and is not used. + - An entry that matches a known-good value is left out: a filename regex that matches a core Windows binary where Windows keeps it (so `.`, `\\` or `(?i)c:`), the empty-file SHA256, or a top domain such as `microsoft.com`. None of the September 2026 entries does. + - A regex that times out is switched off for the rest of the run. + - Matching is capped at 3,000 paths and 30 seconds a run. The Detection Engine now scans users' Downloads, Temp and Roaming folders before `C:\Users\Public`, `C:\ProgramData` and `C:\Windows\Temp`, so the cap and the hash scan's first 100 files are spent there. +- **Cache:** it is trusted only if SYSTEM or Administrators own it. ProgramData lets any local user create a file there and own it, which would let them choose the intel SYSTEM loads, so any other owner's cache is deleted. It must also still parse to 100 or more entries per list; an empty, truncated or corrupt cache no longer passes for current. A modified time in the future counts as stale. The cache is replaced only after all three lists download, so a list that keeps failing never looks current. A failed write is logged, and a cache in the old whole-line format is read correctly. `IntelSource` also reports `Live (Neo23x0, 2 of 3 lists)` and `Cache (download failed)`. +- **Downloads:** they use `-UseBasicParsing`. Without it, Windows PowerShell 5.1 hands a text response to the Internet Explorer engine, which fails under SYSTEM wherever IE's first-run setup was never completed. The progress bar is off in the block. The hash scan skips hashing files when no hash intel is loaded. +- **Measured in each report:** the payload's `intel` object has `hits`, `matches`, `paths_checked`, `paths_skipped`, `match_seconds`, `regex_timeouts`, `list_date` and `min_filename_score`, and the log's METRICS SUMMARY carries the same numbers. Phase 1 takes about 0.5 s plus a 0.65 MB download once a week. Filename matching takes about 1.3 ms a path on PowerShell 7 on Apple Silicon. Windows PowerShell 5.1 is expected to be several times slower, which is what the 30-second cap bounds; the first Windows run should read `match_seconds`. +- **Known limits (misses only, never actions):** matching is case-sensitive as LOKI's is, and `Win32_Process` often reports `C:\WINDOWS\...`, so some process paths will not match. Hash scores are not used; the 86 SHA256 entries scored 55 or 60 (vulnerable libraries and drivers) are `.jar` and `.sys` files, which the hash scan does not hash. +- **Regression test:** new `tests/Test-IntelEngine.ps1` runs Phase 1 verbatim under StrictMode 2, with `Invoke-WebRequest` mocked to serve lists in the real Neo23x0 formats, across 17 scenarios. They cover: + - a fresh download, and CRLF and whitespace lines; + - a current, aged, future-dated, user-owned, empty, `{}`, corrupt, partial and legacy cache; + - one and all lists failing; + - an error page, over 20,000 entries and over 5 MB; + - a disabled engine. + + It asserts `IntelSource`, the loaded counts and what was left out, the cache, and `-UseBasicParsing`. It tests both matchers, including timeouts and the caps, and `Add-IntelHit`'s evidence and caps. It runs every intel consumer verbatim against mocked cmdlets, and asserts each match reported (kind, source, target, would_have), each action taken, and the IOC count. It also checks the whole script's AST for any `$Script:Config.` that the Config literal does not define. + + With the Config fix reverted it fails 22 assertions, and the AST check names the line. Of 23 mutations to the new code, it catches all but one, which the code's other guards make harmless. + ## [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. diff --git a/CONTEXT.md b/CONTEXT.md index 0bce00e..2c6f22e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -61,6 +61,17 @@ Exactly one of: A Finding Class is a property of the finding *type*, not of the host it was found on. +### Intel Match + +Something on a device that matches the threat-intel feed the Intel Engine downloads (Neo23x0 +signature-base: filename regexes, SHA256 hashes, C2 domains and addresses). It can be a process, +a Run value, a startup shortcut, a file, a hosts file entry or a DNS cache entry. Report-only: +it is logged and counted in the Run Report's `intel` object, whose `matches` hold the first 50 +with evidence; the first 20 in a Run are also Low findings titled +`Intel match (report-only): ...`. It is NOT an IOC alert: it does not count in `ioc_alerts` or +the Device Security Score, it raises no Battlefield alert, and nothing is killed or removed +because of it. A match against ShellKnight's own hard-coded lists is an IOC, handled as before. + ### Device Security Score The per-device score ShellKnight computes during a Run, 0 to 100, published on the Fleet Grid as diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index b06386f..285f1ee 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -2,7 +2,7 @@ #Requires -RunAsAdministrator <# .SYNOPSIS - ShellKnight v2026.09.25.003 - Enterprise Endpoint Security & Remediation Tool + ShellKnight v2026.09.25.004 - 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.003 + Version : v2026.09.25.004 Released : 2026-09-25 - Prior : v2026.09.25.002 + Prior : v2026.09.25.003 .ENGINES Phase 1 - Intel Engine : Threat intelligence download and cache @@ -33,6 +33,44 @@ Phase 8 - Reporting Engine : Reporting, trending, and extended checks .CHANGELOG + 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 + not have. Under StrictMode 2 that threw before any download, + cache write or IntelSource, so every device on every run + reported 'Hardcoded fallback' and 0 hash, filename and C2 IOCs, + and the detection engines ran on their hard-coded lists only. + The property is now in Config. The parser kept whole lines + ('hash;comment', 'regex;score'), which no hash or file name + could ever equal. ConvertFrom-IntelFeed now keeps the SHA256, + the domain or IPv4, and the filename regex with its score and + false-positive regex. A filename IOC is a case-sensitive regex + searched for in a full path, as LOKI applies it + (Find-IntelFilenameMatch). Only those scored 60 or more load + (SK_IntelEngine_MinFilenameScore, LOKI's warning level). C2 + names match whole labels and subdomains (Find-IntelC2Match). + REPORT-ONLY. With the parser fixed, a feed match would have + killed a process, removed a Run value, or deleted a startup + shortcut or a redirected-folder file, and each would have been + an IOC (-15, exit code 2, a Critical alert in Battlefield). Now + every intel match goes through Add-IntelHit. It is logged and + counted, and the first 50 go into the new payload object + 'intel' with what a hard-coded match there would do, and the + file's SHA256 and signer. The first 20 become Low findings + 'Intel match (report-only): ...'. It is not an IOC, and nothing + is killed or removed. Hard-coded lists act as before. + Guards against a bad upstream list: + - a list over 5 MB, or with under 100 or over 20,000 usable + entries, is not used; + - entries matching known-good values are left out: a regex + matching a core Windows binary, the empty-file SHA256, or a + top domain; + - a regex that times out is switched off; + - matching is capped at 3,000 paths and 30 s a run. + The cache is trusted only if SYSTEM or Administrators own it and + every list still parses to 100 entries or more. It is replaced + only after all three lists download. Downloads use + -UseBasicParsing (5.1's IE engine fails under SYSTEM). 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) @@ -497,7 +535,7 @@ param() # ============================================================================== -# SHELLKNIGHT v2026.09.25.003 CONFIGURATION +# SHELLKNIGHT v2026.09.25.004 CONFIGURATION # All settings are configured here. No external config files required. # Each engine can be independently enabled or disabled. # ============================================================================== @@ -512,6 +550,9 @@ $SK_IntelEngine_CheckForUpdates = $true # Check remote before downloading (s $SK_IntelEngine_CacheDir = 'C:\ProgramData\ShellKnight\Intel\' $SK_IntelEngine_PrimarySource = 'Neo23x0' # Primary IOC source (future: add more) $SK_IntelEngine_CacheAgeDays = 7 # Force refresh cache after this many days +$SK_IntelEngine_MinFilenameScore = 60 # Load filename IOCs scored at least this: LOKI's warning + # level (below it a match is only a LOKI "notice"). Every + # intel match is REPORT-ONLY - see changelog v2026.09.25.004. # --- ASSESSMENT ENGINE (Phase 2) --- # Establishes machine baseline including hardware, OS, uptime, domain membership, @@ -690,12 +731,14 @@ try { # Runtime Config Object - single source of truth for all engines $Script:Config = [PSCustomObject]@{ - Version = 'v2026.09.25.003' + Version = 'v2026.09.25.004' # Intel Engine IntelEngine_Enabled = $SK_IntelEngine_Enabled IntelEngine_CheckUpdates = $SK_IntelEngine_CheckForUpdates IntelEngine_CacheDir = $SK_IntelEngine_CacheDir IntelEngine_CacheAgeDays = $SK_IntelEngine_CacheAgeDays + IntelEngine_PrimarySource = $SK_IntelEngine_PrimarySource + IntelEngine_MinFilenameScore = $SK_IntelEngine_MinFilenameScore # Assessment Engine AssessmentEngine_Enabled = $SK_AssessmentEngine_Enabled MinSeverity = $SK_AssessmentEngine_MinSeverity @@ -768,6 +811,7 @@ $Script:Counters = @{ Failed = 0 RebootRequired = $false IntelSource = 'Hardcoded fallback' + IntelHits = 0 # report-only intel matches (Add-IntelHit); never in IOCsFound } $Script:SpaceFreed = 0L $Script:RogueScreenConnectRemoved = $false @@ -778,10 +822,28 @@ $Script:LogReady = $false $Script:PSVer = $PSVersionTable.PSVersion.Major $Script:PSFullVer = "$($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor).$($PSVersionTable.PSVersion.Build).$($PSVersionTable.PSVersion.Revision)" -# Pre-compiled IOC collections (populated by Intel Engine) +# Pre-compiled IOC collections (populated by Intel Engine). HashIOCs holds +# lower-case SHA256s and C2IOCs lower-case domains and IPv4s. FilenameIOCs is +# a list of compiled regexes to match against full paths (Find-IntelFilenameMatch), +# not a set of names: the feed's filename IOCs are regexes. $Script:HashIOCs = (New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)) -$Script:FilenameIOCs = (New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)) +$Script:FilenameIOCs = (New-Object 'System.Collections.Generic.List[object]') $Script:C2IOCs = (New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)) +# Find-IntelFilenameMatch runs every loaded filename regex against each path, +# about 2,200 of them at the default score, so matching is capped per run by +# paths and by time. +$Script:IntelPathBudget = 3000 +$Script:IntelTimeBudget = 30 # seconds of filename matching per run +$Script:IntelPathsChecked = 0 +$Script:IntelPathsSkipped = 0 +$Script:IntelRegexTimeouts = 0 +$Script:IntelMatchClock = New-Object System.Diagnostics.Stopwatch +$Script:IntelListDate = $null # when the loaded lists were downloaded +# Add-IntelHit: findings past IntelFindingCap are in the log only; the payload's +# intel.matches holds the first IntelMatchCap in full. +$Script:IntelFindingCap = 20 +$Script:IntelMatchCap = 50 +$Script:IntelMatches = (New-Object 'System.Collections.Generic.List[object]') $Script:FolderIOCs = (New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)) # Single-query caches - populated once, reused across all engines @@ -1133,6 +1195,153 @@ function Write-SectionHeader { param([string]$Title) Log-Info ('-' * 80) } +# ------------------------------------------------------------------------------ +# Threat intel: parsing, matching, and the report-only rule. +# +# The Neo23x0 signature-base lists, in the formats their own headers give: +# filename-iocs.txt REGEX;SCORE[;FALSE-POSITIVE REGEX] +# A case-sensitive regex searched for in a FULL PATH ('(?i)' +# makes one case-insensitive). If the false-positive regex +# also matches, it is not a match. Some are Unix paths. +# hash-iocs.txt HASH;COMMENT or HASH;SCORE;COMMENT - MD5, SHA1 or SHA256 +# c2-iocs.txt DOMAIN or IPV4, a few with ;SCORE +# Up to v2026.09.25.003 the parser kept each whole trimmed line, so a hash +# entry was 'hash;comment' and a filename entry 'regex;score'. Neither could +# ever equal a computed hash or a file name: hash and filename intel could not +# have matched anything even if the engine had loaded (it never did). +# ------------------------------------------------------------------------------ + +# One list's lines -> the entries its consumers can use. A line that does not +# fit is dropped, never guessed at: +# Filename 'regex;score' or 'regex;score;fp' (Unix paths dropped) +# Hashes the SHA256, lower case (the hash scan computes SHA256 only) +# C2 the domain or IPv4, lower case, no trailing dot +# Its output is valid input, so the cache is read back through it as well. +function ConvertFrom-IntelFeed { + param([ValidateSet('Filename','Hashes','C2')][string]$Kind, [string[]]$Lines) + $out = New-Object 'System.Collections.Generic.List[string]' + foreach ($raw in $Lines) { + if ($null -eq $raw) { continue } + # Trim BEFORE testing. A CRLF file's blank lines are "`r", and an empty + # entry would match every Run value and every hosts line. + $line = $raw.Trim() + if (-not $line -or $line.StartsWith('#')) { continue } + $f = $line.Split(';') + $v = $f[0].Trim() + if (-not $v) { continue } + if ($Kind -eq 'Filename') { + $score = 0 + if ($f.Count -lt 2 -or -not [int]::TryParse($f[1].Trim(), [ref]$score)) { continue } + if ($v.StartsWith('/')) { continue } + $fp = if ($f.Count -ge 3) { $f[2].Trim() } else { '' } + if ($fp) { $out.Add("$v;$score;$fp") } else { $out.Add("$v;$score") } + } elseif ($Kind -eq 'Hashes') { + $v = $v.ToLowerInvariant() + if ($v -match '^[0-9a-f]{64}$') { $out.Add($v) } + } else { + $v = $v.ToLowerInvariant().TrimEnd('.') + if ($v -match '^((25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(25[0-5]|2[0-4]\d|1?\d?\d)$' -or + $v -match '^([a-z0-9_]([a-z0-9_-]*[a-z0-9_])?\.)+[a-z][a-z0-9-]*$') { $out.Add($v) } + } + } + , $out +} + +# The first loaded filename IOC whose regex is found in $Path and whose +# false-positive regex is not, or $null. That is the rule LOKI applies. $Path +# must be a full path, or a command line holding one, never a bare name: the +# patterns are anchored on directory separators ('\\usbclass\.sys'). +function Find-IntelFilenameMatch { + param([string]$Path) + if (-not $Path -or $Script:FilenameIOCs.Count -eq 0) { return $null } + if ($Script:IntelPathsChecked -ge $Script:IntelPathBudget -or + $Script:IntelMatchClock.Elapsed.TotalSeconds -ge $Script:IntelTimeBudget) { $Script:IntelPathsSkipped++; return $null } + $Script:IntelPathsChecked++ + $Script:IntelMatchClock.Start() + try { + foreach ($ioc in $Script:FilenameIOCs) { + if ($ioc.Off) { continue } + try { + if ($ioc.Regex.IsMatch($Path) -and -not ($ioc.Exclude -and $ioc.Exclude.IsMatch($Path))) { return $ioc } + } catch { + # Timed out (runaway backtracking): no match, and off for the rest + # of the run, so one bad upstream line cannot cost 250 ms a path. + $ioc.Off = $true + $Script:IntelRegexTimeouts++ + } + } + } finally { $Script:IntelMatchClock.Stop() } + return $null +} + +# The C2 indicator a host name or address matches, or $null. A listed domain +# also matches its subdomains ('evil.example' matches 'x.evil.example'; LOKI +# tests C2 domains as substrings), whole labels only: 'earn.fm' is listed and +# 'learn.fm' is not a match. An address matches only itself. +function Find-IntelC2Match { + param([string]$Name) + if (-not $Name -or $Script:C2IOCs.Count -eq 0) { return $null } + $n = $Name.Trim().ToLowerInvariant().TrimEnd('.') + if (-not $n) { return $null } + if ($Script:C2IOCs.Contains($n)) { return $n } + if ($n -match '^[\d.]+$' -or $n.Contains(':')) { return $null } + $labels = $n.Split('.') + for ($i = 1; $i -lt $labels.Count - 1; $i++) { + $parent = [string]::Join('.', $labels[$i..($labels.Count - 1)]) + if ($Script:C2IOCs.Contains($parent)) { return $parent } + } + return $null +} + +# REPORT-ONLY. Every threat-intel match comes here and goes nowhere else. It is +# logged and counted, the first $Script:IntelFindingCap become Low findings, +# and the first $Script:IntelMatchCap go to the payload's intel.matches with +# the evidence needed to judge them (SHA256 and signer of a matched file). It +# is never an IOC: +# - not in IOCsFound, which costs 15 points each, sets exit code 2 and shows +# the 'Action Required' banner; +# - never a High finding or an 'IOC:' title, which Battlefield alerts on; +# - nothing is killed, stopped or deleted. +# The Intel Engine loaded nothing from v1.002 to v2026.09.25.003, so no intel +# match has ever been seen in the field. They stay report-only until a +# release's worth has been reviewed. $WouldHave says what the consumer does to +# a match from its own hard-coded list, so the data shows what acting would do. +function Add-IntelHit { + param([string]$Kind, [string]$Source, [string]$Target, [string]$Indicator, $Score = $null, + [string]$WouldHave, [string]$File) + $Script:Counters.IntelHits++ + $note = if ($WouldHave) { "report-only; a hard-coded match here $WouldHave" } else { 'report-only' } + $scoreNote = if ($null -ne $Score) { " (score $Score)" } else { '' } # not "$score": that IS $Score + Log-Warn "Intel $Kind match in $Source ($note): $Target - indicator: $Indicator$scoreNote" + if ($Script:IntelMatches.Count -lt $Script:IntelMatchCap) { + $sha = $null; $sigStatus = $null; $signer = $null + if ($File -and (Test-Path -LiteralPath $File -PathType Leaf)) { + try { + if ((Get-Item -LiteralPath $File -Force -ErrorAction Stop).Length -le 100MB) { + $sha = (Get-FileHash -LiteralPath $File -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + } + } catch { } + try { + $sig = Get-AuthenticodeSignature -LiteralPath $File -ErrorAction Stop + $sigStatus = "$($sig.Status)" + if ($sig.SignerCertificate) { $signer = $sig.SignerCertificate.Subject } + } catch { } + } + $Script:IntelMatches.Add([ordered]@{ + kind = $Kind; source = $Source; target = $Target; indicator = $Indicator; score = $Score + would_have = $(if ($WouldHave) { $WouldHave } else { $null }) + sha256 = $sha; signature = $sigStatus; signer = $signer + }) + } + if ($Script:Counters.IntelHits -le $Script:IntelFindingCap) { + Add-Finding -Severity Low -Title "Intel match (report-only): $Kind in $Source - $Target" ` + -Action "Matched threat-intel indicator $Indicator$scoreNote. Not acted on: intel matches are report-only until reviewed. A single match is weak evidence; triage before acting." + } elseif ($Script:Counters.IntelHits -eq $Script:IntelFindingCap + 1) { + Add-Finding -Severity Low -Title "Intel match (report-only): more than $($Script:IntelFindingCap) matches" ` + -Action 'The first 50 are in the report''s intel.matches; all are in the run log (lines starting "Intel").' + } +} + # ============================================================================== # SCRIPT INITIALIZATION @@ -1144,7 +1353,7 @@ $Script:UseNewPSFeatures = $Script:PSVer -ge 5 # Banner $bannerWidth = 78 -$version = 'ShellKnight v2026.09.25.003' +$version = 'ShellKnight v2026.09.25.004' $hostname = $env:COMPUTERNAME $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $psver = "PS $($PSVersionTable.PSVersion.Major).$($PSVersionTable.PSVersion.Minor)" @@ -1246,30 +1455,82 @@ $Script:FallbackFolderIOCs = (New-Object 'System.Collections.Generic.HashSet[str 'reimage','iminlikewithyou','dealply','browsefox' ) | ForEach-Object { $null = $Script:FallbackFolderIOCs.Add($_) } +# Until v2026.09.25.004 this block read $Script:Config.IntelEngine_PrimarySource, +# which the Config object did not have. Under StrictMode 2 that threw in the +# $consolidated literal, before any download, cache write or IntelSource, on +# every run since v1.002: every device ran on the hardcoded fallback and +# reported 0 hash, filename and C2 IOCs. Everything loaded here feeds +# Add-IntelHit, which is report-only. if ($Script:Config.IntelEngine_Enabled) { Invoke-SafeBlock -Label 'Intel Engine' -Block { + # Windows PowerShell 5.1 redraws a progress bar per chunk, which slows + # Invoke-WebRequest many-fold. Local to this block. + $ProgressPreference = 'SilentlyContinue' $cacheDir = $Script:Config.IntelEngine_CacheDir $cacheFile = Join-Path $cacheDir 'neo23x0_consolidated.json' $cacheAge = $Script:Config.IntelEngine_CacheAgeDays - # Neo23x0 IOC sources + # Neo23x0 IOC sources. Kind is the ConvertFrom-IntelFeed format. $sources = @( - @{ Name = 'Filename IOCs'; Url = 'https://raw.githubusercontent.com/Neo23x0/signature-base/master/iocs/filename-iocs.txt' } - @{ Name = 'Hash IOCs'; Url = 'https://raw.githubusercontent.com/Neo23x0/signature-base/master/iocs/hash-iocs.txt' } - @{ Name = 'C2 IOCs'; Url = 'https://raw.githubusercontent.com/Neo23x0/signature-base/master/iocs/c2-iocs.txt' } + @{ Kind = 'Filename'; Name = 'Filename IOCs'; Url = 'https://raw.githubusercontent.com/Neo23x0/signature-base/master/iocs/filename-iocs.txt' } + @{ Kind = 'Hashes'; Name = 'Hash IOCs'; Url = 'https://raw.githubusercontent.com/Neo23x0/signature-base/master/iocs/hash-iocs.txt' } + @{ Kind = 'C2'; Name = 'C2 IOCs'; Url = 'https://raw.githubusercontent.com/Neo23x0/signature-base/master/iocs/c2-iocs.txt' } ) + # Sanity limits for one downloaded list. In September 2026 the three + # hold about 3,700 Windows filename patterns, 1,300 SHA256s and 1,900 + # C2 entries, 0.65 MB in all. A list outside these bounds is an error + # page, a truncated download or a different file. It is not used, and + # the cached copy, if there is one, stands in for it. + $maxChars = 5MB + $minEntries = 100 + $maxEntries = 20000 + + # The cache, if it can be trusted. Only one owned by SYSTEM or + # Administrators: ProgramData lets any local user create a file here and + # then own it, which would let them choose the intel SYSTEM loads, so + # any other is deleted. And only one whose three lists each still parse + # to $minEntries or more (ConvertFrom-IntelFeed, so the old whole-line + # format reads correctly): an empty, truncated or corrupt cache would + # otherwise pass for current for CacheAgeDays. + $intel = @{ Filename = @(); Hashes = @(); C2 = @() } + $cacheDate = $null + if (Test-Path -LiteralPath $cacheFile) { + $owner = try { (Get-Acl -LiteralPath $cacheFile -ErrorAction Stop).GetOwner([System.Security.Principal.SecurityIdentifier]).Value } catch { $null } + if ($owner -notin @('S-1-5-18', 'S-1-5-32-544')) { + Log-Warn "Intel Engine - cache owned by $(if ($owner) { $owner } else { 'an unknown account' }), not SYSTEM or Administrators: deleting it" + Remove-Item -LiteralPath $cacheFile -Force -ErrorAction SilentlyContinue + } else { + try { + $cache = Get-Content -LiteralPath $cacheFile -Raw -ErrorAction Stop | ConvertFrom-Json + $cached = @{ Filename = @(); Hashes = @(); C2 = @() } + foreach ($source in $sources) { + $k = $source.Kind + if ($cache -and $cache.PSObject.Properties[$k]) { $cached[$k] = ConvertFrom-IntelFeed -Kind $k -Lines @($cache.$k) } + } + $short = @($sources | Where-Object { @($cached[$_.Kind]).Count -lt $minEntries } | ForEach-Object { $_.Name }) + if ($short.Count) { throw "too few usable entries in: $($short -join ', ')" } + $intel = $cached + $cacheDate = (Get-Item -LiteralPath $cacheFile).LastWriteTime + # 5.1 keeps the ISO string; PowerShell 7 parses it to a DateTime. + $updated = if ($cache.PSObject.Properties['Updated']) { $cache.Updated } else { $cacheDate } + $Script:IntelListDate = if ($updated -is [datetime]) { $updated.ToString('o') } else { [string]$updated } + } catch { Log-Warn "Intel Engine - cache not usable, ignoring it: $($_.Exception.Message)" } + } + } - $useCache = $false - $cacheExists = Test-Path -LiteralPath $cacheFile - - if ($cacheExists) { - $cacheDate = (Get-Item -LiteralPath $cacheFile).LastWriteTime - $cacheOld = ((Get-Date) - $cacheDate).TotalDays -gt $cacheAge + $useCache = $false + if ($cacheDate) { + # A timestamp in the future is not current: nothing legitimate writes one. + $cacheDays = ((Get-Date) - $cacheDate).TotalDays + $cacheOld = $cacheDays -gt $cacheAge -or $cacheDays -lt 0 if (-not $cacheOld -and $Script:Config.IntelEngine_CheckUpdates) { - # HEAD check - only download if remote has changed + # HEAD check - only download if remote has changed. + # raw.githubusercontent.com sends no Last-Modified (only an ETag), + # so today this lands in the catch, and the cache is used until it + # is IntelEngine_CacheAgeDays old. try { - $headResp = Invoke-WebRequest -Uri $sources[0].Url -Method Head -TimeoutSec 5 -ErrorAction Stop + $headResp = Invoke-WebRequest -Uri $sources[0].Url -Method Head -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop $remoteDate = [datetime]::Parse($headResp.Headers['Last-Modified']) $useCache = $remoteDate -le $cacheDate if ($useCache) { Log-Summary "Intel Engine - cache current, skipping download" } @@ -1280,48 +1541,104 @@ if ($Script:Config.IntelEngine_Enabled) { } if (-not $useCache) { - # Download and consolidate all sources into single cache - $consolidated = @{ - Filename = (New-Object 'System.Collections.Generic.List[string]') - Hashes = (New-Object 'System.Collections.Generic.List[string]') - C2 = (New-Object 'System.Collections.Generic.List[string]') - Updated = (Get-Date).ToString('o') - Source = $Script:Config.IntelEngine_PrimarySource - } - + $fresh = 0 foreach ($source in $sources) { try { - $content = (Invoke-WebRequest -Uri $source.Url -TimeoutSec 30 -ErrorAction Stop).Content - $lines = $content -split "`n" | Where-Object { $_ -and -not $_.StartsWith('#') } - switch -Wildcard ($source.Name) { - 'Filename*' { foreach ($l in $lines) { $consolidated.Filename.Add($l.Trim()) } } - 'Hash*' { foreach ($l in $lines) { $consolidated.Hashes.Add($l.Trim().ToLower()) } } - 'C2*' { foreach ($l in $lines) { $consolidated.C2.Add($l.Trim().ToLower()) } } + # -UseBasicParsing: without it, 5.1 hands a text response to the + # Internet Explorer engine, which fails under SYSTEM wherever IE's + # first-run setup was never completed for that account. + $content = [string](Invoke-WebRequest -Uri $source.Url -UseBasicParsing -TimeoutSec 30 -ErrorAction Stop).Content + if ($content.Length -gt $maxChars) { throw "$($content.Length) characters, over the $($maxChars / 1MB) MB limit" } + $entries = ConvertFrom-IntelFeed -Kind $source.Kind -Lines ($content -split "`n") + if ($entries.Count -lt $minEntries -or $entries.Count -gt $maxEntries) { + throw "$($entries.Count) usable entries, outside the expected $minEntries to $maxEntries" } - Log-Info "Intel Engine - downloaded $($source.Name)" + $intel[$source.Kind] = $entries + $fresh++ + Log-Info "Intel Engine - downloaded $($source.Name): $($entries.Count) usable entries" } catch { - Log-Warn "Intel Engine - failed to download $($source.Name): $($_.Exception.Message)" + $kept = if (@($intel[$source.Kind]).Count) { 'keeping the cached copy' } else { 'no cached copy' } + Log-Warn "Intel Engine - $($source.Name) not updated, $($kept): $($_.Exception.Message)" } } - # Write single consolidated cache file (replace in place) - $consolidated | ConvertTo-Json -Compress | Set-Content -LiteralPath $cacheFile -Encoding UTF8 -Force - $Script:Counters.IntelSource = 'Live (Neo23x0)' - Log-Summary "Intel Engine - cache updated from Neo23x0" + # Replace the cache only when every list downloaded. After a partial + # download the old cache keeps its age, so the next run tries again + # and a list that keeps failing never passes for current. + if ($fresh -eq $sources.Count) { + try { + $Script:IntelListDate = (Get-Date).ToString('o') + @{ Filename = $intel.Filename; Hashes = $intel.Hashes; C2 = $intel.C2 + Updated = $Script:IntelListDate; Source = $Script:Config.IntelEngine_PrimarySource } | + ConvertTo-Json -Compress | Set-Content -LiteralPath $cacheFile -Encoding UTF8 -Force -ErrorAction Stop + Log-Summary "Intel Engine - cache updated from Neo23x0" + } catch { Log-Warn "Intel Engine - cache not written: $($_.Exception.Message)" } + } elseif ($fresh) { + $Script:IntelListDate = "$((Get-Date).ToString('o')) (partial)" + } + $Script:Counters.IntelSource = if ($fresh -eq $sources.Count) { 'Live (Neo23x0)' } + elseif ($fresh) { "Live (Neo23x0, $fresh of $($sources.Count) lists)" } + elseif ($cacheDate) { 'Cache (download failed)' } + else { 'Hardcoded fallback' } } else { $Script:Counters.IntelSource = 'Cache (current)' } - # Load consolidated cache into hash sets for O(1) lookup - if (Test-Path -LiteralPath $cacheFile) { - $cache = Get-Content -LiteralPath $cacheFile -Raw | ConvertFrom-Json - if ($cache.Hashes) { foreach ($h in $cache.Hashes) { $null = $Script:HashIOCs.Add($h) } } - if ($cache.Filename) { foreach ($f in $cache.Filename) { $null = $Script:FilenameIOCs.Add($f) } } - if ($cache.C2) { foreach ($c in $cache.C2) { $null = $Script:C2IOCs.Add($c) } } - $Script:HashIOCsLoaded = $Script:HashIOCs.Count - $Script:FilenameIOCsLoaded = $Script:FilenameIOCs.Count - $Script:C2IOCsLoaded = $Script:C2IOCs.Count - Log-Summary "Intel Engine - $($Script:HashIOCsLoaded) hash IOCs | $($Script:FilenameIOCsLoaded) filename IOCs | $($Script:C2IOCsLoaded) C2 IOCs loaded" + # Known good: an entry that matches one of these is over-broad or wrong, + # and is left out. No entry in the September 2026 lists does. It is the + # guard against an upstream line such as '.', '\\' or '(?i)c:', which + # would match every path on every device (and, once intel acts, act on + # every device at once). A filename regex that times out on these is + # left out too. + $goodPaths = @( + 'C:\Windows\explorer.exe', 'C:\Windows\System32\svchost.exe', 'C:\Windows\System32\lsass.exe', + 'C:\Windows\System32\services.exe', 'C:\Windows\System32\winlogon.exe', 'C:\Windows\System32\csrss.exe', + 'C:\Windows\System32\taskhostw.exe', 'C:\Windows\System32\RuntimeBroker.exe', 'C:\Windows\System32\cmd.exe', + 'C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe', 'C:\Windows\SysWOW64\rundll32.exe', + 'C:\Windows\System32\drivers\etc\hosts', 'C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe', + 'C:\Program Files\Common Files\microsoft shared\ClickToRun\OfficeClickToRun.exe', + 'C:\Program Files\WindowsApps\Microsoft.WindowsStore_22408.1401.3.0_x64__8wekyb3d8bbwe\WinStore.App.exe' + ) + $goodHashes = @('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') # the empty file + $goodC2 = @('microsoft.com', 'windows.com', 'windowsupdate.com', 'office.com', 'office365.com', 'live.com', + 'outlook.com', 'azure.com', 'msftconnecttest.com', 'google.com', 'gstatic.com', 'googleapis.com', + 'github.com', 'githubusercontent.com', 'apple.com', 'amazonaws.com', 'cloudflare.com', + 'akamaiedge.net', 'centrastage.net', 'datto.com', 'ptechllc.com', + '0.0.0.0', '127.0.0.1', '8.8.8.8', '1.1.1.1') + + # Load into the runtime sets. Filename regexes are compiled once here, + # case-sensitive as LOKI applies them, with a match timeout. + $knownGood = 0 + foreach ($h in $intel.Hashes) { if ($goodHashes -contains $h) { $knownGood++ } else { $null = $Script:HashIOCs.Add($h) } } + foreach ($c in $intel.C2) { if ($goodC2 -contains $c) { $knownGood++ } else { $null = $Script:C2IOCs.Add($c) } } + $rxOpts = [System.Text.RegularExpressions.RegexOptions]::None + $rxTimeout = [timespan]::FromMilliseconds(250) + $lowScore = 0 + $badRegex = 0 + $overBroad = 0 + foreach ($e in $intel.Filename) { + $f = $e.Split(';') + if ([int]$f[1] -lt $Script:Config.IntelEngine_MinFilenameScore) { $lowScore++; continue } + try { + $rx = New-Object System.Text.RegularExpressions.Regex -ArgumentList $f[0], $rxOpts, $rxTimeout + $fp = $null + if ($f.Count -ge 3) { $fp = New-Object System.Text.RegularExpressions.Regex -ArgumentList $f[2], $rxOpts, $rxTimeout } + } catch { $badRegex++; continue } + $broad = $false + foreach ($p in $goodPaths) { + try { if ($rx.IsMatch($p) -and -not ($fp -and $fp.IsMatch($p))) { $broad = $true; break } } + catch { $broad = $true; break } + } + if ($broad) { $overBroad++; continue } + $Script:FilenameIOCs.Add([pscustomobject]@{ Pattern = $f[0]; Score = [int]$f[1]; Regex = $rx; Exclude = $fp; Off = $false }) + } + $Script:HashIOCsLoaded = $Script:HashIOCs.Count + $Script:FilenameIOCsLoaded = $Script:FilenameIOCs.Count + $Script:C2IOCsLoaded = $Script:C2IOCs.Count + Log-Summary "Intel Engine - $($Script:HashIOCsLoaded) hash IOCs | $($Script:FilenameIOCsLoaded) filename IOCs | $($Script:C2IOCsLoaded) C2 IOCs loaded (matches are report-only)" + if ($lowScore -or $badRegex -or $overBroad -or $knownGood) { + Log-Info ("Intel Engine - left out: $lowScore filename IOCs scored below $($Script:Config.IntelEngine_MinFilenameScore), " + + "$badRegex not valid .NET regex, $overBroad matching a known-good path; $knownGood known-good hashes or C2 entries") } } } else { @@ -1876,6 +2193,18 @@ if ($Script:Config.ProcessEngine_Enabled) { 'C:\Program Files (x86)\' ) + # Executable paths for the intel filename check, which matches full paths. + # One CIM query rather than one per process; a process it cannot see is not + # checked. + $procPathById = @{} + if ($Script:FilenameIOCs.Count) { + try { + foreach ($wp in @(Get-CimInstance Win32_Process -ErrorAction Stop)) { + if ($wp.ExecutablePath) { $procPathById[[int]$wp.ProcessId] = $wp.ExecutablePath } + } + } catch { Log-Info "Process Engine - process paths unavailable, intel filename check skipped: $($_.Exception.Message)" } + } + $killedProcs = 0 foreach ($proc in $Script:Cache_Processes) { if ($Script:LegitProcessNames.Contains($proc.Name)) { continue } @@ -1890,12 +2219,12 @@ if ($Script:Config.ProcessEngine_Enabled) { # which would miss a real 'conti_v3'. Separators must still count as edges. $isMalware = $malwareProcPatterns | Where-Object { $proc.Name -match ('(? $($entry.Data)" -Indicator $dnsC2 $c2Hits++ } } @@ -3472,7 +3840,7 @@ $freeAfterGB = if ($diskAfter) { [math]::Round($diskAfter.FreeSpace / 1GB, 1) } $sepLine = '=' * 80 Log-Info $sepLine -Log-Info " ShellKnight v2026.09.25.003 - Report" +Log-Info " ShellKnight v2026.09.25.004 - Report" Log-Info " Hostname : $($env:COMPUTERNAME)" Log-Info " Run Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" Log-Info " Runtime : $runtime seconds" @@ -3485,7 +3853,7 @@ Log-Info $sepLine $bannerWidth2 = 78 Write-Host '' Write-Host " $sepLine" -ForegroundColor Cyan -Write-Host " ShellKnight v2026.09.25.003 - Report" -ForegroundColor Cyan +Write-Host " ShellKnight v2026.09.25.004 - 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 @@ -3498,6 +3866,7 @@ Write-Host " EXECUTIVE SUMMARY - BEFORE / AFTER" -ForegroundColor Cyan Write-Host " ============================================================================" -ForegroundColor Cyan Write-Host " Disk Free : $freeGB GB -> $freeAfterGB GB (+$([math]::Round($freeAfterGB - $freeGB,1)) GB net)" -ForegroundColor White Write-Host " IOC Alerts : $($Script:Counters.IOCsFound)" -ForegroundColor $(if ($Script:Counters.IOCsFound -gt 0) { 'Red' } else { 'Green' }) +Write-Host " Intel Match : $($Script:Counters.IntelHits) (report-only)" -ForegroundColor $(if ($Script:Counters.IntelHits -gt 0) { 'Yellow' } else { 'White' }) Write-Host " Actions Done: $($Script:Counters.ActionsTaken)" -ForegroundColor White Write-Host " Failed : $($Script:Counters.Failed)" -ForegroundColor $(if ($Script:Counters.Failed) { 'Red' } else { 'White' }) Write-Host " ============================================================================" -ForegroundColor Cyan @@ -3511,6 +3880,7 @@ Log-Info " BEFORE AFTER" Log-Info " ------ -----" Log-Info " Disk Free : $freeGB GB Disk Free : $freeAfterGB GB (+$([math]::Round($freeAfterGB - $freeGB,1)) GB net / $freedGBGross GB gross freed)" Log-Info " IOC Alerts : $($Script:Counters.IOCsFound)" +Log-Info " Intel Match : $($Script:Counters.IntelHits) (report-only)" Log-Info " Warnings : Actions Done : $($Script:Counters.ActionsTaken)" Log-Info " Failed : $($Script:Counters.Failed)" Log-Info ' ============================================================================' @@ -3529,6 +3899,8 @@ Log-Info " Hash IOCs loaded $($Script:HashIOCsLoaded)" Log-Info " Filename IOCs loaded $($Script:FilenameIOCsLoaded)" Log-Info " C2 IOCs loaded $($Script:C2IOCsLoaded)" Log-Info " Intel source $($Script:Counters.IntelSource)" +Log-Info " Intel matches $($Script:Counters.IntelHits) (report-only: not IOC alerts, nothing acted on)" +Log-Info " Intel paths checked $($Script:IntelPathsChecked) in $([math]::Round($Script:IntelMatchClock.Elapsed.TotalSeconds, 1)) s$(if ($Script:IntelPathsSkipped) { "; $($Script:IntelPathsSkipped) more over the cap ($($Script:IntelPathBudget) paths / $($Script:IntelTimeBudget) s), not checked" })$(if ($Script:IntelRegexTimeouts) { "; $($Script:IntelRegexTimeouts) regex(es) timed out and switched off" })" Log-Info " Total actions taken $($Script:Counters.ActionsTaken)" Log-Info " Failed actions $($Script:Counters.Failed)" Log-Info " IOC alerts $($Script:Counters.IOCsFound)" @@ -3757,7 +4129,7 @@ $jsonStamp= Get-Date -Format 'yyyy-MM-dd_HHmm' $jsonPath = "$jsonDir\ShellKnight_${jsonStamp}_$($env:COMPUTERNAME).json" $jsonData = [ordered]@{ - version = 'v2026.09.25.003' + version = 'v2026.09.25.004' device_id = $Script:DeviceId hardware_type = $Script:MachineInfo['Hardware Type'] site_name = $SK_SiteName @@ -3800,6 +4172,17 @@ $jsonData = [ordered]@{ hash_iocs_loaded = $Script:HashIOCsLoaded filename_iocs = $Script:FilenameIOCsLoaded c2_iocs = $Script:C2IOCsLoaded + # Report-only intel matches (Add-IntelHit): never in ioc_alerts or the score. + intel = [ordered]@{ + hits = $Script:Counters.IntelHits + matches = @($Script:IntelMatches) # the first 50, with evidence + paths_checked = $Script:IntelPathsChecked + paths_skipped = $Script:IntelPathsSkipped # over the per-run cap, not checked + match_seconds = [math]::Round($Script:IntelMatchClock.Elapsed.TotalSeconds, 1) + regex_timeouts = $Script:IntelRegexTimeouts + list_date = $Script:IntelListDate + min_filename_score = $Script:Config.IntelEngine_MinFilenameScore + } failed_actions = $Script:Counters.Failed findings = @($Script:Findings | ForEach-Object { [ordered]@{ severity = $_.Severity; title = $_.Title; action = $_.Action } }) log_path = $Script:LogPath diff --git a/tests/Test-IntelEngine.ps1 b/tests/Test-IntelEngine.ps1 new file mode 100644 index 0000000..ad2e843 --- /dev/null +++ b/tests/Test-IntelEngine.ps1 @@ -0,0 +1,653 @@ +<# +.SYNOPSIS + Regression test: the Intel Engine loads threat intel, and every intel match + is report-only. + +.DESCRIPTION + From v1.002 to v2026.09.25.003 the Intel Engine read + $Script:Config.IntelEngine_PrimarySource, which the Config object did not + have. Under StrictMode 2 that threw inside the Invoke-SafeBlock, before any + download, cache write or IntelSource, so every device on every run reported + intel_source 'Hardcoded fallback' and 0 hash, filename and C2 IOCs. The + parser behind it kept whole lines ('hash;comment', 'regex;score'), which no + hash or file name could ever equal. + + Loading intel for the first time turns on detections that have never run in + the field, next to consumers that kill processes and delete Run values, + shortcuts and files. So an intel match is report-only: counted, logged, + listed in the payload's intel object and (the first 20) a Low finding, but + never an IOC, never a kill or a removal. + + This runs Phase 1 verbatim from ShellKnight.ps1 under StrictMode 2, with + Invoke-WebRequest mocked to serve lists in the real Neo23x0 formats, and + asserts the cache, IntelSource and the counts. It then runs every intel + consumer verbatim - the Process Engine's process loop, the Persistence + Engine's Run keys and startup shortcuts, the redirected-folder scan and + the Detection Engine's filename, hash, hosts file and DNS checks - against + mocked Windows cmdlets, and asserts each match it reports and each action + it takes. It does not replace a real Windows run. + + It also parses the whole script and fails on any $Script:Config. + that the Config literal does not define: the general form of the bug. + + ShellKnight.ps1 is a monolith that executes on load, so the code is + extracted textually rather than dot-sourced. +#> +Set-StrictMode -Version 2 +# The test's own logic stops on any error. Only the extracted ShellKnight code +# runs under the script's own 'SilentlyContinue' (see Invoke-Verbatim). +$ErrorActionPreference = 'Stop' + +$scriptPath = Join-Path (Split-Path $PSScriptRoot -Parent) 'ShellKnight.ps1' +$source = Get-Content -LiteralPath $scriptPath -Raw + +function Get-Section { + param([string]$Pattern, [string]$What) + $m = [regex]::Match($source, $Pattern) + if (-not $m.Success) { throw "$What not found in ShellKnight.ps1 - did it get renamed or moved?" } + $m.Value +} + +$settings = Get-Section '(?ms)^# --- INTEL ENGINE \(Phase 1\) ---.*?(?=^\$Script:ConfigPath)' 'the $SK_ settings' +$configLit = Get-Section '(?ms)^\$Script:Config = \[PSCustomObject\]@\{.*?^\}' 'the $Script:Config literal' +$countersLit= Get-Section '(?ms)^\$Script:Counters = @\{.*?^\}' 'the $Script:Counters literal' +$intelState = Get-Section '(?ms)^\$Script:HashIOCs = .*?^\$Script:IntelMatches\s+=[^\r\n]*' 'the intel collections and state' +$functions = foreach ($fn in 'Invoke-SafeBlock', 'ConvertFrom-IntelFeed', 'Find-IntelFilenameMatch', 'Find-IntelC2Match', 'Add-IntelHit', 'Log-IOC') { + Get-Section "(?ms)^function $fn\s+\{.*?^\}" "function $fn" +} +$phase1 = Get-Section ('(?ms)^\$Script:HashIOCsLoaded = 0.*?' + + '^ \$Script:Counters.IntelSource = ''Disabled \(fallback only\)''\s*^\}') 'Phase 1 (the Intel Engine)' +$procLoop = Get-Section '(?ms)^ # Known malware process patterns.*?^ if \(\$killedProcs -eq 0\) \{[^\r\n]*\}' 'the Process Engine process loop' +$persist = Get-Section '(?ms)^ # Known malware Run key executables.*?^ if \(\$lnksRemoved -eq 0\) \{[^\r\n]*\}' 'the Persistence Engine Run key and startup checks' +$redirected = Get-Section "(?ms)^ Invoke-SafeBlock -Label 'Redirected folder scan' -Block \{.*?^ \}" 'the redirected folder scan' +$detection = Get-Section ('(?ms)^ # Trojan/Malware folder IOC detection.*?' + + '^ if \(\$c2Hits -eq 0\) \{[^\r\n]*\}\s*^ \}') 'the Detection Engine IOC checks' + +# --- Mocks common to every part ---------------------------------------------- +function Say { param([string]$m, [string]$c = 'Gray') Microsoft.PowerShell.Utility\Write-Host $m -ForegroundColor $c } +function Write-Host { } +$Script:Logged = New-Object 'System.Collections.Generic.List[string]' +$Script:Findings = New-Object 'System.Collections.Generic.List[object]' +function Write-Log { param([string]$Message, [string]$Level) $Script:Logged.Add("$($Level): $Message") } +function Log-Info { param([string]$m) $Script:Logged.Add("INFO: $m") } +function Log-Warn { param([string]$m) $Script:Logged.Add("WARN: $m") } +function Log-Summary { param([string]$m) $Script:Logged.Add("SUMMARY: $m") } +function Log-Success { param([string]$m) $Script:Logged.Add("SUCCESS: $m") } +function Log-Fail { param([string]$m) $Script:Logged.Add("FAILED: $m") } +function Add-Finding { param($Severity, $Title, $Action) $Script:Findings.Add([pscustomobject]@{ Severity = $Severity; Title = $Title }) } + +# The web: one entry per list file name. A string is served as the body; $null +# fails the request. A HEAD answers like raw.githubusercontent.com: no +# Last-Modified. +$Script:Web = @{} +$Script:WebCalls = New-Object 'System.Collections.Generic.List[object]' +function Invoke-WebRequest { + param($Uri, $Method = 'Get', $TimeoutSec, $ErrorAction, [switch]$UseBasicParsing) + $leaf = ([string]$Uri).Split('/')[-1] + $Script:WebCalls.Add([pscustomobject]@{ Leaf = $leaf; Method = $Method; Basic = [bool]$UseBasicParsing }) + if ($Method -eq 'Head') { return [pscustomobject]@{ Headers = @{ ETag = '"abc"' }; Content = '' } } + if ($null -eq $Script:Web[$leaf]) { throw 'The remote server returned an error: (503) Server Unavailable.' } + [pscustomobject]@{ Content = $Script:Web[$leaf]; Headers = @{} } +} +# The cache file's owner, as a SID. Get-Acl does not exist off Windows. +$Script:CacheOwner = 'S-1-5-18' +function Get-Acl { + param($LiteralPath, $ErrorAction) + $acl = [pscustomobject]@{} + $acl | Add-Member ScriptMethod GetOwner { param($Type) [pscustomobject]@{ Value = $Script:CacheOwner } } + $acl +} +function Get-AuthenticodeSignature { + param($LiteralPath, $ErrorAction) + [pscustomobject]@{ Status = 'Valid'; SignerCertificate = [pscustomobject]@{ Subject = 'CN=SKTEST Vendor' } } +} + +foreach ($f in $functions) { Invoke-Expression $f } + +# Run ShellKnight code as the script runs it: StrictMode 2 (the test's own) +# and SilentlyContinue. Dot-sourced into this function's scope, so the +# preference is this scope's and ends with it; $Script: writes reach the test. +function Invoke-Verbatim([string]$Code) { + $ErrorActionPreference = 'SilentlyContinue' + . ([scriptblock]::Create($Code)) +} + +$failures = 0 +function Fail([string]$Label, [string]$Why) { + Say " FAIL $Label - $Why" Red + $script:failures++ +} + +# --- Lists in the real Neo23x0 formats --------------------------------------- +# Every kind of line the real files have (see each file's own header), plus +# fillers so each list clears the engine's 100-entry sanity floor, plus the +# over-broad and known-good entries the engine must leave out. The indicators +# are made up; none is a real IOC. +$sha = [System.Security.Cryptography.SHA256]::Create() +function Get-TestHash([string]$Seed) { -join ($sha.ComputeHash([Text.Encoding]::UTF8.GetBytes($Seed)) | ForEach-Object { $_.ToString('x2') }) } +$hashEvil = Get-TestHash 'sktest-hashed.dll' +$hashUpper = (Get-TestHash 'upper').ToUpper() +$hashScored = Get-TestHash 'scored' +$hashEmpty = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' + +function New-Feed([string]$Kind, [string]$Nl = "`n", [int]$Fill = 120) { + $lines = switch ($Kind) { + 'filename-iocs.txt' { + '#'; '# LOKI File Name Characteristics' + '# Every line is treated as REGEX case sensitive. Prepend (?i) to make it case insensitive' + '# REGEX;SCORE[;EXCLUDE FALSE POSITIVE REGEX]'; '#'; '' + '# SKTEST family' + '\\sktest-evil\.exe;80' + '\\sktest-weak\.exe;45' # below the minimum score (60) + '(?i)\\SKTEST-CASE\.dll;70' # case-insensitive by its own (?i) + '\\sktest-case2\.dll;70' # case-sensitive, like every line without (?i) + '\\sktest-fp\.exe;75;\\Vendor\\' # not a match under \Vendor\ + '\\Startup\\sktest-shortcut\.lnk;70' + '/tmp/sktest-unix;80' # a Unix path: dropped + '\\sktest-broken(\.exe;80' # not a valid regex: left out, counted + '(?i)\\windows\\;90' # over-broad: matches known-good paths + '\\;70' # over-broad: any backslash + '' + foreach ($i in 1..$Fill) { '\\sktest-filler-{0:d5}\.exe;60' -f $i } + } + 'hash-iocs.txt' { + '#'; '# LOKI CUSTOM EVIL HASHES'; '# MD5;COMMENT'; '# SHA1;COMMENT'; '# SHA256;COMMENT'; '#'; '' + "$hashEvil;SKTEST family - PE32 executable (DLL) (GUI) Intel 80386" + "$hashUpper;SKTEST upper case" + 'd41d8cd98f00b204e9800998ecf8427e;an MD5, which the SHA256 scan cannot use' + 'da39a3ee5e6b4b0d3255bfef95601890afd80709;a SHA1, likewise' + "$hashScored;55;Vulnerable library ./lib/sktest-1.0.jar" + "$hashEmpty;the empty file: known good" + '' + foreach ($i in 1..$Fill) { "$(Get-TestHash "filler$i");SKTEST filler $i" } + } + 'c2-iocs.txt' { + '#'; '# LOKI C2 IOCs'; '# c2-server.tld'; '# ip-address'; '#'; '' + '# SKTEST family' + 'sktest-c2.example' + '203.0.113.7' + '198.51.100.9;65' + 'Sktest-Upper.Example.' + 'not a domain' + 'microsoft.com' # known good + '' + foreach ($i in 1..$Fill) { 'sktest-filler-{0:d5}.example' -f $i } + } + } + $lines -join $Nl +} +$leaves = 'filename-iocs.txt', 'hash-iocs.txt', 'c2-iocs.txt' +function Set-Web([string]$Nl = "`n") { foreach ($l in $leaves) { $Script:Web[$l] = New-Feed $l $Nl } } +# Loaded from the feeds above: filename = 5 named at 70-80 + 120 fillers at 60 +# (the 45 is below the minimum, the broken one does not compile, the two +# over-broad ones match known-good paths, the Unix one is dropped); hashes = 3 +# SHA256 + 120 (MD5, SHA1 and the empty file left out); C2 = 4 + 120 +# (microsoft.com left out). +$want = @{ Hash = 123; Filename = 125; C2 = 124 } +$leftOut = 'left out: 1 filename IOCs scored below 60, 1 not valid \.NET regex, 2 matching a known-good path; 2 known-good hashes or C2 entries' + +# What the pre-v2026.09.25.004 parser would have cached: whole trimmed lines, +# hashes and C2 lower-cased. $Only keeps one list's key and empties the rest. +function New-LegacyCache([string]$Path, [string]$Empty) { + $old = @{} + foreach ($pair in @(@('Filename', 'filename-iocs.txt', $false), @('Hashes', 'hash-iocs.txt', $true), @('C2', 'c2-iocs.txt', $true))) { + $old[$pair[0]] = @((New-Feed $pair[1]) -split "`n" | Where-Object { $_ -and -not $_.StartsWith('#') } | + ForEach-Object { if ($pair[2]) { $_.Trim().ToLower() } else { $_.Trim() } }) + } + if ($Empty) { $old[$Empty] = @() } + $old.Updated = (Get-Date).ToString('o'); $old.Source = 'Neo23x0' + $old | ConvertTo-Json -Compress | Set-Content -LiteralPath $Path -Encoding UTF8 +} + +# --- Phase 1 ------------------------------------------------------------------ +# Only Phase 1 touches disk: its cache lives in a temp directory that the +# finally below deletes. Everything after it uses the sets it loaded. +$tmpRoot = Join-Path ([System.IO.Path]::GetTempPath()) ('sk-intel-test-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $tmpRoot +try { + Invoke-Expression $settings + $SK_IntelEngine_CacheDir = $tmpRoot + $cacheFile = Join-Path $tmpRoot 'neo23x0_consolidated.json' + + function Reset-Intel { + Invoke-Expression $configLit + Invoke-Expression $countersLit + Invoke-Expression $intelState + $Script:Logged.Clear(); $Script:Findings.Clear(); $Script:WebCalls.Clear() + } + function Get-CacheStamp { if (Test-Path -LiteralPath $cacheFile) { (Get-Item -LiteralPath $cacheFile).LastWriteTimeUtc.Ticks } else { $null } } + # No cache to date means Phase 1 did not write one; the scenario's own + # assertions report that, and the rest of the test still runs. + function Set-CacheAge([int]$Days) { if (Test-Path -LiteralPath $cacheFile) { (Get-Item -LiteralPath $cacheFile).LastWriteTime = (Get-Date).AddDays(-$Days) } } + # A good cache, written by Phase 1 itself. + function New-GoodCache { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel } + + # Each scenario: Setup (web and cache), then what must hold after Phase 1. + # Source: IntelSource. Counts: 'full' ($want), 'none' (all 0), or a hashtable. + # Cache: 'written' (new or replaced), 'kept' (untouched), 'absent'. + # Log: a line that must have been logged. + $scenarios = @( + @{ Name = 'fresh download'; Setup = { Set-Web }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3 } + # A CRLF file's blank lines are "`r", and whitespace-only lines must not + # become an empty entry that matches every path. + @{ Name = 'CRLF and whitespace lines'; Setup = { Set-Web "`r`n"; foreach ($l in $leaves) { $Script:Web[$l] = $Script:Web[$l] + "`r`n `r`n`t`r`n" } }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3 } + @{ Name = 'cache current'; Setup = { New-GoodCache }; + Source = 'Cache (current)'; Counts = 'full'; Cache = 'kept'; Gets = 0 } + # A partial refresh does not rewrite the cache, so it keeps its age and + # the next run tries again. + @{ Name = 'cache aged, one list fails'; Setup = { New-GoodCache; Set-CacheAge 10; $Script:Web['hash-iocs.txt'] = $null }; + Source = 'Live (Neo23x0, 2 of 3 lists)'; Counts = 'full'; Cache = 'kept'; Gets = 3 } + @{ Name = 'cache aged, all lists fail'; Setup = { New-GoodCache; Set-CacheAge 10; $Script:Web.Clear() }; + Source = 'Cache (download failed)'; Counts = 'full'; Cache = 'kept'; Gets = 3 } + @{ Name = 'cache dated in the future'; Setup = { New-GoodCache; Set-CacheAge -30 }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3 } + # A local user can create the cache in ProgramData and own it. + @{ Name = 'cache owned by a user'; Setup = { New-GoodCache; $Script:CacheOwner = 'S-1-5-21-1-2-3-1001' }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3; Log = 'not SYSTEM or Administrators: deleting it' } + @{ Name = 'cache empty file'; Setup = { Set-Web; [System.IO.File]::WriteAllText($cacheFile, '') }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3; Log = 'cache not usable' } + @{ Name = 'cache {}'; Setup = { Set-Web; [System.IO.File]::WriteAllText($cacheFile, '{}') }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3; Log = 'cache not usable' } + @{ Name = 'cache corrupt'; Setup = { Set-Web; [System.IO.File]::WriteAllText($cacheFile, '{"Filename":["\\x.exe;80"') }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3; Log = 'cache not usable' } + @{ Name = 'cache current, one list empty'; Setup = { Set-Web; New-LegacyCache $cacheFile 'Hashes' }; + Source = 'Live (Neo23x0)'; Counts = 'full'; Cache = 'written'; Gets = 3; Log = 'cache not usable' } + @{ Name = 'legacy whole-line cache'; Setup = { New-LegacyCache $cacheFile }; + Source = 'Cache (current)'; Counts = 'full'; Cache = 'kept'; Gets = 0 } + @{ Name = 'no cache, all lists fail'; Setup = { $Script:Web.Clear() }; + Source = 'Hardcoded fallback'; Counts = 'none'; Cache = 'absent'; Gets = 3 } + # A captive portal or proxy error page parses to nothing: below the floor. + @{ Name = 'error page'; Setup = { foreach ($l in $leaves) { $Script:Web[$l] = "`n

502 Bad Gateway

" } }; + Source = 'Hardcoded fallback'; Counts = 'none'; Cache = 'absent'; Gets = 3 } + # Over a limit, that list is not used; with no cached copy the other + # two are used this run and the cache is not written. + @{ Name = 'over 20,000 entries'; Setup = { Set-Web; $Script:Web['filename-iocs.txt'] = New-Feed 'filename-iocs.txt' "`n" 20001 }; + Source = 'Live (Neo23x0, 2 of 3 lists)'; Counts = @{ Hash = 123; Filename = 0; C2 = 124 }; Cache = 'absent'; Gets = 3; Log = 'outside the expected' } + @{ Name = 'over 5 MB'; Setup = { Set-Web; $Script:Web['filename-iocs.txt'] = $Script:Web['filename-iocs.txt'] + "`n#" + ('x' * 5300000) }; + Source = 'Live (Neo23x0, 2 of 3 lists)'; Counts = @{ Hash = 123; Filename = 0; C2 = 124 }; Cache = 'absent'; Gets = 3; Log = 'over the 5 MB limit' } + @{ Name = 'engine disabled'; Setup = { Set-Web; $Script:Config.IntelEngine_Enabled = $false }; + Source = 'Disabled (fallback only)'; Counts = 'none'; Cache = 'absent'; Gets = 0 } + ) + + Say '' + Say ' Intel Engine: intel loads, and every match is report-only (StrictMode 2)' + Say ' -------------------------------------------------------------------------' + + foreach ($sc in $scenarios) { + $label = "phase 1: $($sc.Name)" + $before = $failures + if (Test-Path -LiteralPath $cacheFile) { Remove-Item -LiteralPath $cacheFile -Force } + $Script:Web.Clear() + $Script:CacheOwner = 'S-1-5-18' + Reset-Intel + & $sc.Setup + $stampBefore = Get-CacheStamp + $Script:WebCalls.Clear() + + Invoke-Verbatim $phase1 + + $skipped = @($Script:Logged | Where-Object { $_ -match 'Intel Engine skipped' }) + if ($skipped.Count) { Fail $label "the engine aborted: $($skipped -join ' | ')" } + if ($Script:Counters.IntelSource -ne $sc.Source) { Fail $label "IntelSource '$($Script:Counters.IntelSource)', expected '$($sc.Source)'" } + + $counts = if ($sc.Counts -eq 'full') { $want } elseif ($sc.Counts -eq 'none') { @{ Hash = 0; Filename = 0; C2 = 0 } } else { $sc.Counts } + $got = @{ Hash = $Script:HashIOCsLoaded; Filename = $Script:FilenameIOCsLoaded; C2 = $Script:C2IOCsLoaded } + foreach ($k in 'Hash', 'Filename', 'C2') { + if ($got[$k] -ne $counts[$k]) { Fail $label "$k IOCs loaded = $($got[$k]), expected $($counts[$k])" } + } + # The counts are the sets, and the sets hold usable entries only. + if ($Script:HashIOCs.Count -ne $got.Hash -or $Script:FilenameIOCs.Count -ne $got.Filename -or $Script:C2IOCs.Count -ne $got.C2) { + Fail $label 'the *IOCsLoaded counts do not match the loaded sets' + } + $badHash = @($Script:HashIOCs | Where-Object { $_ -notmatch '^[0-9a-f]{64}$' -or $_ -eq $hashEmpty }) + $badC2 = @($Script:C2IOCs | Where-Object { -not $_ -or $_.Contains(';') -or $_.Contains(' ') -or $_ -eq 'microsoft.com' }) + $badFn = @($Script:FilenameIOCs | Where-Object { -not $_.Pattern -or $_.Pattern.Contains(';') -or $_.Score -lt 60 -or $_.Off }) + if ($badHash.Count) { Fail $label "hash entries that are not a SHA256, or known good: $($badHash[0])" } + if ($badC2.Count) { Fail $label "C2 entries that are not a bare name or address, or known good: '$($badC2[0])'" } + if ($badFn.Count) { Fail $label "filename entries with a ';', under the minimum score, or off: $($badFn[0].Pattern)" } + + $stampAfter = Get-CacheStamp + switch ($sc.Cache) { + 'written' { if ($null -eq $stampAfter -or $stampAfter -eq $stampBefore) { Fail $label 'expected the cache to be written' } } + 'kept' { if ($null -eq $stampAfter -or $stampAfter -ne $stampBefore) { Fail $label 'expected the cache to be left as it was' } } + 'absent' { if ($null -ne $stampAfter) { Fail $label 'expected no cache file' } } + } + $gets = @($Script:WebCalls | Where-Object { $_.Method -ne 'Head' }) + if ($gets.Count -ne $sc.Gets) { Fail $label "$($gets.Count) list downloads, expected $($sc.Gets)" } + # Without -UseBasicParsing, 5.1 hands the response to Internet Explorer's + # engine, which fails under SYSTEM where IE's first run was never completed. + if (@($Script:WebCalls | Where-Object { -not $_.Basic }).Count) { Fail $label 'a web request without -UseBasicParsing' } + if ($sc.ContainsKey('Log') -and -not @($Script:Logged | Where-Object { $_ -like "*$($sc.Log)*" }).Count) { + Fail $label "expected a log line containing '$($sc.Log)'" + } + if ($sc.Counts -eq 'full' -and -not @($Script:Logged | Where-Object { $_ -match $leftOut }).Count) { + Fail $label "expected the log to say what was left out and why: '$leftOut'" + } + + if ($failures -eq $before) { + Say " ok $label - $($Script:Counters.IntelSource); hash $($got.Hash), filename $($got.Filename), C2 $($got.C2)" Green + } + } + + # --- Add-IntelHit: report-only ---------------------------------------------- + Reset-Intel + $af = $failures + $evidence = Join-Path $tmpRoot 'sktest-evidence.exe' + [System.IO.File]::WriteAllText($evidence, 'sktest evidence') + $evidenceSha = (Get-TestHash 'sktest evidence') + Add-IntelHit -Kind 'filename' -Source 'process' -Target "$evidence (PID 1)" -File $evidence -Indicator '\\sktest-evidence\.exe' -Score 80 -WouldHave 'kills the process' + foreach ($i in 2..60) { Add-IntelHit -Kind 'C2' -Source 'DNS cache' -Target "x$i.example -> 203.0.113.7" -Indicator '203.0.113.7' } + if ($Script:Counters.IntelHits -ne 60) { Fail 'Add-IntelHit' "IntelHits = $($Script:Counters.IntelHits), expected 60" } + if ($Script:Counters.IOCsFound -ne 0) { Fail 'Add-IntelHit' "IOCsFound = $($Script:Counters.IOCsFound): an intel match must not be an IOC alert" } + $intelF = @($Script:Findings | Where-Object { $_.Title -like 'Intel match (report-only):*' }) + if ($intelF.Count -ne 21) { Fail 'Add-IntelHit' "$($intelF.Count) findings, expected 20 and one 'more than 20'" } + if (@($Script:Findings | Where-Object { $_.Severity -ne 'Low' -or $_.Title -match '^(?i)IOC' }).Count) { + Fail 'Add-IntelHit' 'a finding that is not Low, or whose title starts with IOC (Battlefield alerts on both)' + } + if ($Script:IntelMatches.Count -ne 50) { Fail 'Add-IntelHit' "$($Script:IntelMatches.Count) entries in intel.matches, expected the cap of 50" } + $first = $Script:IntelMatches[0] + if ($first.sha256 -ne $evidenceSha -or $first.signer -ne 'CN=SKTEST Vendor' -or $first.signature -ne 'Valid' -or + $first.would_have -ne 'kills the process' -or $first.score -ne 80 -or $first.source -ne 'process') { + Fail 'Add-IntelHit' "the first match's evidence is wrong: $(($first.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join '; ')" + } + if ($null -ne $Script:IntelMatches[1].sha256 -or $null -ne $Script:IntelMatches[1].would_have) { Fail 'Add-IntelHit' 'evidence or would_have filled in for a match with no file or action' } + if ($failures -eq $af) { Say ' ok Add-IntelHit - counted, 50 in intel.matches with SHA256 and signer, 20 Low findings, never an IOC' Green } + + # Fresh load: the matcher and consumer tests below use these sets. + if (Test-Path -LiteralPath $cacheFile) { Remove-Item -LiteralPath $cacheFile -Force } + Reset-Intel; Set-Web; Invoke-Verbatim $phase1 +} finally { + # .NET, not Remove-Item: the consumer tests below mock Remove-Item. + if ([System.IO.Directory]::Exists($tmpRoot)) { [System.IO.Directory]::Delete($tmpRoot, $true) } +} + +# --- Find-IntelFilenameMatch: LOKI's rule -------------------------------------- +$matchCases = @( + @('C:\Users\bob\AppData\Local\Temp\sktest-evil.exe', '\\sktest-evil\.exe', 'a full path'), + @('sktest-evil.exe', $null, 'a bare name: patterns anchor on \'), + @('C:\Users\bob\sktest-weak.exe', $null, 'a pattern under the minimum score'), + @('C:\Users\bob\SKTEST-CASE.DLL', '(?i)\\SKTEST-CASE\.dll', 'its own (?i)'), + @('C:\Users\bob\SKTEST-CASE2.DLL', $null, 'case-sensitive by default'), + @('C:\Users\bob\sktest-case2.dll', '\\sktest-case2\.dll', 'exact case'), + @('C:\Program Files\Vendor\sktest-fp.exe', $null, 'its false-positive regex'), + @('C:\Users\bob\Downloads\sktest-fp.exe', '\\sktest-fp\.exe', 'outside the false-positive path'), + @('"C:\Users\Public\sktest-evil.exe" /quiet', '\\sktest-evil\.exe', 'a command line'), + @('C:\Windows\System32\svchost.exe', $null, 'a known-good path (the over-broad entries are out)'), + @('', $null, 'an empty path') +) +$mf = $failures +foreach ($c in $matchCases) { + $m = Find-IntelFilenameMatch -Path $c[0] + $got = if ($m) { $m.Pattern } else { $null } + if ($got -ne $c[1]) { Fail "match: $($c[2])" "'$($c[0])' matched $(if ($got) { "'$got'" } else { 'nothing' }), expected $(if ($c[1]) { "'$($c[1])'" } else { 'nothing' })" } +} +# A regex that times out is switched off after its first timeout. +$slow = [pscustomobject]@{ Pattern = '^(a+)+b$'; Score = 60; Exclude = $null; Off = $false + Regex = (New-Object System.Text.RegularExpressions.Regex -ArgumentList '^(a+)+b$', ([System.Text.RegularExpressions.RegexOptions]::None), ([timespan]::FromMilliseconds(5))) } +$Script:FilenameIOCs.Insert(0, $slow) +$null = Find-IntelFilenameMatch -Path (('a' * 40) + '!') +$null = Find-IntelFilenameMatch -Path (('a' * 40) + '!') +if (-not $slow.Off -or $Script:IntelRegexTimeouts -ne 1) { Fail 'match: timeout' "a timed-out regex is not switched off (Off=$($slow.Off), timeouts=$($Script:IntelRegexTimeouts))" } +$Script:FilenameIOCs.RemoveAt(0) +# The per-run caps: paths, then time. +$Script:IntelPathBudget = $Script:IntelPathsChecked + 1 +$first = Find-IntelFilenameMatch -Path 'C:\Users\bob\sktest-evil.exe' +$second = Find-IntelFilenameMatch -Path 'C:\Users\bob\sktest-evil.exe' +if (-not $first -or $second -or $Script:IntelPathsSkipped -ne 1) { Fail 'match: path cap' "the path after the cap was checked, or not counted as skipped ($($Script:IntelPathsSkipped))" } +$Script:IntelPathBudget = 3000; $Script:IntelTimeBudget = 0 +if ((Find-IntelFilenameMatch -Path 'C:\Users\bob\sktest-evil.exe') -or $Script:IntelPathsSkipped -ne 2) { Fail 'match: time cap' 'a path was checked after the time budget ran out' } +$Script:IntelTimeBudget = 30 +if ($failures -eq $mf) { Say " ok matcher - full paths, case, (?i), false-positive regex, command lines, timeouts, and the per-run caps" Green } + +# --- Find-IntelC2Match: whole labels, subdomains, addresses --------------------- +$c2Cases = @( + @('sktest-c2.example', 'sktest-c2.example'), + @('SKTEST-C2.Example.', 'sktest-c2.example'), + @('beacon.sktest-c2.example', 'sktest-c2.example'), + @('a.b.sktest-c2.example', 'sktest-c2.example'), + @('notsktest-c2.example', $null), + @('sktest-c2.example.evil', $null), + @('203.0.113.7', '203.0.113.7'), + @('1.203.0.113.7', $null), + @('microsoft.com', $null), + @('www.microsoft.com', $null), + @('', $null) +) +$cf = $failures +foreach ($c in $c2Cases) { + $got = Find-IntelC2Match $c[0] + if ($got -ne $c[1]) { Fail "C2 match: '$($c[0])'" "matched $(if ($got) { "'$got'" } else { 'nothing' }), expected $(if ($c[1]) { "'$($c[1])'" } else { 'nothing' })" } +} +if ($failures -eq $cf) { Say ' ok C2 matcher - exact and subdomains by whole labels, addresses exactly, known-good left out' Green } + +# --- The consumers --------------------------------------------------------------- +# Only the checks' own inputs are mocked. Every match against the SKTEST intel +# must be reported through Add-IntelHit and acted on nowhere; a match against +# a hard-coded list must still be acted on as before. +$Script:Actions = New-Object 'System.Collections.Generic.List[string]' +$Script:Dirs = @{} # directory -> child directory names +$Script:Files = @{} # directory -> file names +$Script:Reg = @{} # registry key -> values +$Script:Procs = @() # pscustomobject Name, Id, Path +$Script:Hosts = @() +$Script:Dns = @() +$Script:FileHashes = @{} +$Script:HashCalls = 0 +$Script:HkuSids = @() + +function Join-Path { param([Parameter(Position = 0)]$Path, [Parameter(Position = 1)]$ChildPath) "$(([string]$Path).TrimEnd('\'))\$ChildPath" } +function Test-Path { + param([Parameter(Position = 0)]$Path, $LiteralPath, $PathType) + $p = if ($LiteralPath) { $LiteralPath } else { $Path } + $Script:Dirs.ContainsKey($p) -or $Script:Files.ContainsKey($p) -or $Script:Reg.ContainsKey($p) +} +function Get-ChildItem { + param([Parameter(Position = 0)]$Path, $LiteralPath, $Filter, [switch]$Directory, [switch]$File, [switch]$Force, [switch]$Recurse) + $p = if ($LiteralPath) { $LiteralPath } else { $Path } + if ($p -eq 'HKU:\') { return @($Script:HkuSids | ForEach-Object { [pscustomobject]@{ PSChildName = $_ } }) } + if ($Recurse) { return @() } # the ransomware canary walk: nothing encrypted + if ($Directory) { return @(@($Script:Dirs[$p]) | Where-Object { $_ } | ForEach-Object { [pscustomobject]@{ Name = $_; FullName = "$p\$_" } }) } + $names = @(@($Script:Files[$p]) | Where-Object { $_ }) + if ($Filter) { $names = @($names | Where-Object { $_ -like $Filter }) } + @($names | ForEach-Object { + [pscustomobject]@{ Name = $_; BaseName = [System.IO.Path]::GetFileNameWithoutExtension($_) + Extension = [System.IO.Path]::GetExtension($_); FullName = "$p\$_" } }) +} +function Get-ItemProperty { param([Parameter(Position = 0)]$Path, $Name) if ($Script:Reg[$Path]) { [pscustomobject]$Script:Reg[$Path] } } +function Remove-ItemProperty { param($Path, $Name, [switch]$Force, $ErrorAction) $Script:Actions.Add("remove value $Path\$Name") } +function Remove-Item { param([Parameter(Position = 0)]$Path, $LiteralPath, [switch]$Force, [switch]$Recurse) $Script:Actions.Add("delete $LiteralPath$Path") } +function Stop-Process { param($Id, [switch]$Force, $ErrorAction) $Script:Actions.Add("kill $Id") } +function Get-Process { + param($Id, $ErrorAction) + $p = @($Script:Procs | Where-Object { $_.Id -eq $Id }) + if ($p.Count) { $p[0] } elseif ($ErrorAction -eq 'Stop') { throw "no process $Id" } +} +function Get-CimInstance { + param([Parameter(Position = 0)]$ClassName, $Filter, $Namespace) + if ($ClassName -ne 'Win32_Process') { throw "unmocked CIM class $ClassName" } + $all = @($Script:Procs | ForEach-Object { [pscustomobject]@{ ProcessId = [uint32]$_.Id; ExecutablePath = $_.Path } }) + if ($Filter -match 'ProcessId=(\d+)') { $all = @($all | Where-Object { $_.ProcessId -eq [uint32]$Matches[1] }) } + $all +} +function Get-PSDrive { + param($Name, $PSProvider, $ErrorAction) + if ($Name -eq 'HKU') { return [pscustomobject]@{ Name = 'HKU' } } + @([pscustomobject]@{ Root = 'C:\' }, [pscustomobject]@{ Root = 'D:\' }) +} +function New-PSDrive { throw 'New-PSDrive should not be needed: the HKU drive is mocked as present' } +function Get-FileHash { + param($LiteralPath, $Algorithm, $ErrorAction) + $Script:HashCalls++ + [pscustomobject]@{ Hash = $(if ($Script:FileHashes[$LiteralPath]) { $Script:FileHashes[$LiteralPath].ToUpper() } else { 'AB' * 32 }) } +} +function Get-Content { param($LiteralPath, $ErrorAction, [switch]$Raw) if ($LiteralPath -like '*\drivers\etc\hosts') { $Script:Hosts } else { throw "unmocked file $LiteralPath" } } +function Get-NetTCPConnection { param($State, $ErrorAction) @() } +function Get-DnsClientCache { param($ErrorAction) $Script:Dns } + +$Script:LegitProcessNames = New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase) +$null = $Script:LegitProcessNames.Add('Zoom') +$Script:LegitDropFiles = @('PsExec.exe') +$Script:HostsWhitelist = @('granicus.com') +$Script:CanaryWhitelist = @() + +function Reset-World { + Invoke-Expression $countersLit + $Script:IntelPathBudget = 3000; $Script:IntelTimeBudget = 30 + $Script:IntelPathsChecked = 0; $Script:IntelPathsSkipped = 0; $Script:IntelRegexTimeouts = 0 + $Script:IntelMatchClock.Reset(); $Script:IntelMatches.Clear() + $Script:Logged.Clear(); $Script:Findings.Clear(); $Script:Actions.Clear() + $Script:Dirs = @{}; $Script:Files = @{}; $Script:Reg = @{}; $Script:Procs = @(); $Script:Hosts = @(); $Script:Dns = @() + $Script:FileHashes = @{}; $Script:HashCalls = 0; $Script:HkuSids = @() +} + +$detectionFiles = { + $Script:Dirs['C:\Users'] = @('bob') + $Script:Dirs['C:\Users\bob\Downloads'] = @() + $Script:Files['C:\Users\bob\Downloads'] = @('sktest-evil.exe', 'sktest-hashed.dll', 'invoice.pdf') + $Script:FileHashes['C:\Users\bob\Downloads\sktest-hashed.dll'] = $hashEvil +} + +# Each consumer: the world it sees, the code, and every intel match it must +# report ('kind|source|target|would_have', -like patterns), the actions it must +# take (hard-coded matches only) and its IOC count. Blocks: how many hosts +# lines must be logged as blocking a C2 name. HashCalls: files hashed. +$consumers = @( + @{ Name = 'Process Engine'; Code = $procLoop + Setup = { + $Script:Procs = @( + [pscustomobject]@{ Name = 'sktest-evil'; Id = 4101; CPU = 1.0; Path = 'C:\Users\bob\AppData\Local\Temp\sktest-evil.exe' } + [pscustomobject]@{ Name = 'njrat'; Id = 4102; CPU = 1.0; Path = 'C:\Users\bob\AppData\Roaming\njrat.exe' } + [pscustomobject]@{ Name = 'NVDisplay.Container'; Id = 4103; CPU = 1.0; Path = 'C:\Program Files\NVIDIA Corporation\Display.NvContainer\NVDisplay.Container.exe' } + [pscustomobject]@{ Name = 'sktest-evil'; Id = 4104; CPU = 1.0; Path = 'C:\Program Files\SkVendor\sktest-evil.exe' } + ) + $Script:Cache_Processes = $Script:Procs + } + Matches = @('filename|process|C:\Users\bob\AppData\Local\Temp\sktest-evil.exe (PID 4101)|kills the process' + 'filename|process|C:\Program Files\SkVendor\sktest-evil.exe (PID 4104)|is only reported (vendor path)') + Actions = @('kill 4102'); Iocs = 1 } + @{ Name = 'Persistence Engine'; Code = $persist + Setup = { + $run = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' + $Script:Reg[$run] = [ordered]@{ SkEvil = '"C:\Users\Public\sktest-evil.exe" /q'; Njrat = 'C:\ProgramData\njrat.exe' + OneDrive = '"C:\Program Files\Microsoft OneDrive\OneDrive.exe" /background' } + $Script:HkuSids = @('S-1-5-21-1-2-3-1001', 'S-1-5-21-1-2-3-1001_Classes', 'S-1-5-18') + $Script:Reg['HKU:\S-1-5-21-1-2-3-1001\SOFTWARE\Microsoft\Windows\CurrentVersion\Run'] = [ordered]@{ SkEvilUser = 'C:\Users\bob\AppData\Roaming\sktest-evil.exe' } + $Script:Dirs['C:\Users'] = @('bob', 'Public') + $startup = 'C:\Users\bob\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup' + $Script:Files[$startup] = @('sktest-shortcut.lnk', 'Send to OneNote.lnk') + } + Matches = @('filename|Run value|HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\SkEvil = "C:\Users\Public\sktest-evil.exe" /q|removes the Run value' + 'filename|Run value|HKU:\S-1-5-21-1-2-3-1001\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\SkEvilUser = C:\Users\bob\AppData\Roaming\sktest-evil.exe (user: *)|removes the Run value' + 'filename|startup shortcut|C:\Users\bob\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup\sktest-shortcut.lnk|deletes the shortcut') + Actions = @('remove value HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\Njrat'); Iocs = 1 } + @{ Name = 'Redirected folder scan'; Code = $redirected + Setup = { + $Script:Dirs['D:\Users'] = @('bob') + $Script:Files['D:\Users\bob\Downloads'] = @('sktest-evil.exe', 'toolbar-setup.exe', 'report.pdf') + } + Matches = @('filename|redirected folder|D:\Users\bob\Downloads\sktest-evil.exe|deletes the file') + Actions = @('delete D:\Users\bob\Downloads\toolbar-setup.exe'); Iocs = 1 } + @{ Name = 'Detection Engine'; Code = $detection + Setup = { + & $detectionFiles + $Script:Hosts = @( + '# Copyright (c) 1993-2009 Microsoft Corp.' + '127.0.0.1 localhost' + '10.0.0.6 sktest-c2.example # C2, pointed at a routable address' + "10.0.0.8`tgood.local`tapi.sktest-c2.example" + '203.0.113.7 printer.local # a C2 address' + '0.0.0.0 sktest-c2.example # blocked' + '::1 sktest-c2.example # blocked' + '0:0:0:0:0:0:0:0 sktest-c2.example # blocked' + '10.0.0.5 notsktest-c2.example # a longer name ending the same way: no match' + '10.0.0.9 fine.local # in a comment, no match: sktest-c2.example' + '10.0.0.7 intranet.corp.local' + ) + $Script:Dns = @([pscustomobject]@{ Entry = 'sktest-c2.example.'; Data = '10.1.1.1' }, + [pscustomobject]@{ Entry = 'beacon.sktest-c2.example'; Data = '10.1.1.2' }, + [pscustomobject]@{ Entry = 'cdn.benign.example'; Data = '203.0.113.7' }, + [pscustomobject]@{ Entry = 'www.microsoft.com'; Data = '23.1.2.3' }) + } + Matches = @('filename|scanned file|C:\Users\bob\Downloads\sktest-evil.exe|' + 'hash|scanned file|C:\Users\bob\Downloads\sktest-hashed.dll|' + 'C2|hosts file|10.0.0.6 sktest-c2.example # C2, pointed at a routable address|' + "C2|hosts file|10.0.0.8`tgood.local`tapi.sktest-c2.example|" + 'C2|hosts file|203.0.113.7 printer.local # a C2 address|' + 'C2|DNS cache|sktest-c2.example. -> 10.1.1.1|' + 'C2|DNS cache|beacon.sktest-c2.example -> 10.1.1.2|' + 'C2|DNS cache|cdn.benign.example -> 203.0.113.7|') + Actions = @(); Iocs = 0; Blocks = 3; HashCalls = 2 } + # With no hash intel loaded, no file is hashed. + @{ Name = 'Detection Engine, no hash intel'; Code = $detection + Setup = { & $detectionFiles; $Script:HashIOCs.Clear() } + Matches = @('filename|scanned file|C:\Users\bob\Downloads\sktest-evil.exe|') + Actions = @(); Iocs = 0; Blocks = 0; HashCalls = 0 } +) + +foreach ($c in $consumers) { + $label = "consumer: $($c.Name)" + $before = $failures + Reset-World + & $c.Setup + Invoke-Verbatim $c.Code + + $skipped = @($Script:Logged | Where-Object { $_ -match ' skipped - ' }) + if ($skipped.Count) { Fail $label "a block aborted: $($skipped -join ' | ')" } + if ($Script:Counters.IntelHits -ne $c.Matches.Count) { Fail $label "$($Script:Counters.IntelHits) intel matches counted, expected $($c.Matches.Count)" } + $got = @($Script:IntelMatches | ForEach-Object { "$($_.kind)|$($_.source)|$($_.target)|$($_.would_have)" }) + $missing = @($c.Matches | Where-Object { $p = $_; -not @($got | Where-Object { $_ -like $p }).Count }) + $extra = @($got | Where-Object { $g = $_; -not @($c.Matches | Where-Object { $g -like $_ }).Count }) + if ($missing.Count) { Fail $label "intel matches not reported: $($missing -join ' || ')" } + if ($extra.Count) { Fail $label "unexpected intel matches: $($extra -join ' || ')" } + $acts = @($Script:Actions) + if (($acts -join '|') -ne ($c.Actions -join '|')) { + Fail $label "actions taken: [$($acts -join '; ')], expected [$($c.Actions -join '; ')] (hard-coded matches only)" + } + if ($Script:Counters.IOCsFound -ne $c.Iocs) { Fail $label "IOCsFound = $($Script:Counters.IOCsFound), expected $($c.Iocs) (hard-coded matches only)" } + $intelF = @($Script:Findings | Where-Object { $_.Title -like 'Intel match (report-only):*' }) + if ($intelF.Count -ne $c.Matches.Count -or @($intelF | Where-Object { $_.Severity -ne 'Low' }).Count) { + Fail $label "$($intelF.Count) Low intel findings, expected $($c.Matches.Count)" + } + if ($c.ContainsKey('Blocks')) { + $blocks = @($Script:Logged | Where-Object { $_ -like 'INFO: Hosts file blocks C2 name(s) sktest-c2.example:*' }).Count + if ($blocks -ne $c.Blocks) { Fail $label "$blocks hosts lines logged as blocking a C2 name, expected $($c.Blocks)" } + } + if ($c.ContainsKey('HashCalls') -and $Script:HashCalls -ne $c.HashCalls) { Fail $label "$($Script:HashCalls) files hashed, expected $($c.HashCalls)" } + if ($failures -eq $before) { + Say " ok $label - $($c.Matches.Count) intel match(es) reported, none acted on; hard-coded actions: $(if ($acts.Count) { $acts -join '; ' } else { 'none' })" Green + } +} + +# --- Static: every $Script:Config. exists in the Config literal ---------- +# Under StrictMode 2, reading a property a PSCustomObject does not have throws, +# and Invoke-SafeBlock turns that into a skipped engine with one INFO line in +# the log. That is how the Intel Engine went unnoticed from v1.002 on. +$tokens = $null; $parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile($scriptPath, [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw "ShellKnight.ps1 does not parse: $($parseErrors[0].Message)" } +$isConfig = { param($n) $n -is [System.Management.Automation.Language.VariableExpressionAst] -and $n.VariablePath.UserPath -eq 'Script:Config' } +$cfgAssign = @($ast.FindAll({ param($n) + $n -is [System.Management.Automation.Language.AssignmentStatementAst] -and (& $isConfig $n.Left) }, $true)) +if ($cfgAssign.Count -ne 1) { throw "expected one assignment to `$Script:Config, found $($cfgAssign.Count)" } +$literal = $cfgAssign[0].Right.Find({ param($n) $n -is [System.Management.Automation.Language.HashtableAst] }, $true) +if (-not $literal) { throw 'the $Script:Config assignment has no hashtable literal' } +$defined = @($literal.KeyValuePairs | ForEach-Object { $_.Item1.Value }) +$undefined = New-Object 'System.Collections.Generic.List[string]' +foreach ($m in $ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.MemberExpressionAst] -and (& $isConfig $n.Expression) }, $true)) { + if ($m.Member -isnot [System.Management.Automation.Language.StringConstantExpressionAst]) { + $undefined.Add("line $($m.Extent.StartLineNumber): a computed member '$($m.Member.Extent.Text)' cannot be checked"); continue + } + if ($m.Member.Value -notin $defined) { $undefined.Add("line $($m.Extent.StartLineNumber): `$Script:Config.$($m.Member.Value) is not in the Config literal") } +} +if ($undefined.Count) { foreach ($u in $undefined) { Fail 'config' $u } } +else { Say " ok config - every `$Script:Config. the script reads is in the Config literal ($($defined.Count) defined)" Green } + +Say '' +if ($failures -gt 0) { + Say " FAILED - $failures assertion(s)" Red + exit 1 +} +Say ' PASS - all assertions' Green +exit 0