From d80e03b5bbf21cb57088012faa2aa31954d81b8d Mon Sep 17 00:00:00 2001 From: cdburgess75 <508435+cdburgess75@users.noreply.github.com> Date: Fri, 25 Sep 2026 08:49:57 -0500 Subject: [PATCH 1/2] v2026.09.25.004: the Intel Engine loads threat intel; every intel match is report-only Since v1.002 the Intel 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. The property is now in Config. The parser kept whole lines ('hash;comment', 'regex;score'), which no hash or file name could equal. ConvertFrom-IntelFeed keeps the SHA256, the domain or IPv4, and the filename regex with its score and false-positive regex; Find-IntelFilenameMatch applies filename IOCs as LOKI does (case-sensitive regex over a full path), scored 60 or more. Intel has never loaded in the field, and its consumers kill processes and remove Run values, shortcuts and files. Every intel match now goes through Add-IntelHit: logged, counted (intel_hits), a Low finding, never an IOC, and never acted on. Hard-coded lists act as before. Hosts-file C2 matching is by whole name. Downloads use -UseBasicParsing; list sizes are sanity-checked and the cache is only replaced when every list has entries. New tests/Test-IntelEngine.ps1 runs Phase 1 and every intel consumer verbatim under StrictMode 2, and checks every $Script:Config. via the AST. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 + CONTEXT.md | 11 + ShellKnight.ps1 | 408 +++++++++++++++++++++++------ tests/Test-IntelEngine.ps1 | 516 +++++++++++++++++++++++++++++++++++++ 4 files changed, 864 insertions(+), 81 deletions(-) create mode 100644 tests/Test-IntelEngine.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c5841a..3186dac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # 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 only ever had the ~30 names in `$Script:FallbackFolderIOCs`. The failure was one INFO line in the log: `Intel Engine skipped - The property 'IntelEngine_PrimarySource' cannot be found on this object.` 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 if the engine had loaded. The new `ConvertFrom-IntelFeed` follows each file's own header. From `hash-iocs.txt` it keeps the SHA256, lower case; 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 before it tests a line: 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. +- **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, consumers compared them with exact names (`Contains($proc.Name)`) or as escaped literal substrings, which could never match. 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 that a single match is a LOKI "notice", and no single filename IOC reaches LOKI's alert level of 100. That is 2,184 of 3,707 Windows patterns in the September 2026 list. Checks are capped at 3,000 paths per run; the log reports paths checked and seconds taken, and the payload reports `intel_paths_skipped`. +- **Every intel match is report-only (fleet safety):** this is the first time intel has loaded in the field. As written, a feed match would have killed a process outside Program Files and Windows (Process Engine), removed a Run value or deleted a startup shortcut (Persistence Engine), or deleted a file from a redirected folder (Filesystem Engine). It would also have raised an IOC: -15 points, exit code 2, and a Critical alert in Battlefield. Against the September 2026 list, 3 of a hand-picked 52 common, legitimate Windows paths match at score 60 or more, including K7's own AV binary in Program Files (`\\k7sysmon\.exe;60`). Every intel match now goes through `Add-IntelHit`. It is logged with what a hard-coded match would have done, and counted in the new payload field `intel_hits`; the first 20 per run become Low findings titled `Intel match (report-only): ...`. It is not an IOC: it is not in `ioc_alerts`, the score or the exit code, Battlefield raises no alert for it (Low severity, and the title does not start with "IOC"), and 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_hits` has been reviewed. +- **C2 matching by whole name:** the hosts file check matched C2 entries as unanchored substrings, so `earn.fm` would have matched `learn.fm`. It now compares whole names and addresses. A C2 name pointed at `0.0.0.0` or loopback is a block that a blocklist added, and is logged as such rather than reported. The DNS cache check was already exact. +- **Download and cache robustness:** the downloads 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. A list over 5 MB, or with under 100 or over 20,000 usable entries, is treated as an error page or a wrong file and not used; the cached copy stands in for it. The cache is replaced only when every list has entries, so a failed download no longer blanks a list for `CacheAgeDays`. A cache in the old whole-line format is read correctly. `IntelSource` now also reports `Live (Neo23x0, 2 of 3 lists)` and `Cache (download failed)`. The hash scan skips hashing files when no hash intel is loaded. +- **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. Its 10 scenarios cover a fresh download, CRLF and whitespace lines, a current cache, one and all lists failing, an error page, an oversize list, a legacy cache and a disabled engine. It asserts `IntelSource`, the loaded counts, the cache and `-UseBasicParsing`. It then runs every intel consumer verbatim against mocked cmdlets and asserts that an intel match is reported and not acted on, while hard-coded matches still are. 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 42 assertions, and the AST check names the line. + ## [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..d41a515 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, counted in `intel_hits`, and reported as a Low finding 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 still an IOC and is still +acted on. + ### 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..06c9784 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,37 @@ 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. + 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). + Downloads use -UseBasicParsing (5.1's IE engine fails under + SYSTEM). A list over 5 MB, or with under 100 or over 20,000 + usable entries, is not used, and the cache is replaced only + when every list has entries. + REPORT-ONLY. An intel match (Add-IntelHit) is logged, counted in + the new payload field intel_hits, and the first 20 become Low + findings 'Intel match (report-only): ...'. It is never an IOC: + not in ioc_alerts or the score, no exit code 2, no Battlefield + alert, and nothing is killed or removed. As written, a feed match + would have killed a process outside Program Files and Windows, + removed a Run value, deleted a startup shortcut, or deleted a + file in a redirected folder. Hard-coded lists act as before. + Hosts-file C2 matching is by whole name, not substring, and a C2 + name pointed at 0.0.0.0 or loopback is a block, not a match. + Filename checks are capped at 3,000 paths per run + (intel_paths_skipped). Runtime grows by the Phase 1 download + (about 0.65 MB, weekly) and a few seconds of matching. 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 +528,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 +543,10 @@ $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 60 a single match is only a LOKI "notice"; no single + # filename IOC reaches LOKI's alert level (100). 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 +725,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 +805,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 +816,20 @@ $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 paths checked per run are capped. +$Script:IntelPathBudget = 3000 +$Script:IntelPathsChecked = 0 +$Script:IntelPathsSkipped = 0 +$Script:IntelMatchClock = New-Object System.Diagnostics.Stopwatch +$Script:IntelFindingCap = 20 # Add-IntelHit: findings past this are in the log only $Script:FolderIOCs = (New-Object 'System.Collections.Generic.HashSet[string]' -ArgumentList ([System.StringComparer]::OrdinalIgnoreCase)) # Single-query caches - populated once, reused across all engines @@ -1133,6 +1181,104 @@ 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) { $Script:IntelPathsSkipped++; return $null } + $Script:IntelPathsChecked++ + $Script:IntelMatchClock.Start() + try { + foreach ($ioc in $Script:FilenameIOCs) { + # A regex that times out (runaway backtracking) counts as no match. + try { + if ($ioc.Regex.IsMatch($Path) -and -not ($ioc.Exclude -and $ioc.Exclude.IsMatch($Path))) { return $ioc } + } catch { } + } + } finally { $Script:IntelMatchClock.Stop() } + return $null +} + +# REPORT-ONLY. Every threat-intel match comes here and goes nowhere else. It is +# logged and counted, and the first $Script:IntelFindingCap become Low +# findings. 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 log shows what acting would do. +function Add-IntelHit { + param([string]$Kind, [string]$Where, [string]$Indicator, [string]$WouldHave) + $Script:Counters.IntelHits++ + $note = if ($WouldHave) { "report-only; a hard-coded match here $WouldHave" } else { 'report-only' } + Log-Warn "Intel $Kind match ($note): $Where - indicator: $Indicator" + if ($Script:Counters.IntelHits -le $Script:IntelFindingCap) { + Add-Finding -Severity Low -Title "Intel match (report-only): $Kind - $Where" ` + -Action "Matched threat-intel indicator $Indicator. 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 rest are in the run log only (lines starting "Intel").' + } +} + # ============================================================================== # SCRIPT INITIALIZATION @@ -1144,7 +1290,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 +1392,61 @@ $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 cached lists. They are read back through ConvertFrom-IntelFeed + # below, so a cache in the old whole-line format loads correctly too. + $intel = @{ Filename = @(); Hashes = @(); C2 = @() } + $cacheDate = $null + if (Test-Path -LiteralPath $cacheFile) { + try { + $cache = Get-Content -LiteralPath $cacheFile -Raw -ErrorAction Stop | ConvertFrom-Json + foreach ($k in @('Filename', 'Hashes', 'C2')) { + if ($cache.PSObject.Properties[$k] -and $cache.$k) { $intel[$k] = @($cache.$k) } + } + $cacheDate = (Get-Item -LiteralPath $cacheFile).LastWriteTime + } catch { Log-Warn "Intel Engine - cache unreadable, 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) { + $cacheOld = ((Get-Date) - $cacheDate).TotalDays -gt $cacheAge 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 +1457,72 @@ 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 has entries, fresh or carried + # over. Writing an empty list would hide it for CacheAgeDays; left + # unwritten, the next run downloads again. + $missing = @($sources | Where-Object { -not @($intel[$_.Kind]).Count } | ForEach-Object { $_.Name }) + if ($fresh -and -not $missing.Count) { + @{ Filename = $intel.Filename; Hashes = $intel.Hashes; C2 = $intel.C2 + Updated = (Get-Date).ToString('o'); Source = $Script:Config.IntelEngine_PrimarySource } | + ConvertTo-Json -Compress | Set-Content -LiteralPath $cacheFile -Encoding UTF8 -Force + Log-Summary "Intel Engine - cache updated from Neo23x0" + } elseif ($fresh) { + Log-Warn "Intel Engine - cache not written, no copy of: $($missing -join ', ')" + } + $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" + # Load into the runtime sets. Filename regexes are compiled once here, + # case-sensitive as LOKI applies them, with a match timeout. One below + # the minimum score, or that .NET cannot compile, is left out and counted. + foreach ($h in (ConvertFrom-IntelFeed -Kind Hashes -Lines $intel.Hashes)) { $null = $Script:HashIOCs.Add($h) } + foreach ($c in (ConvertFrom-IntelFeed -Kind C2 -Lines $intel.C2)) { $null = $Script:C2IOCs.Add($c) } + $rxOpts = [System.Text.RegularExpressions.RegexOptions]::None + $rxTimeout = [timespan]::FromMilliseconds(250) + $lowScore = 0 + $badRegex = 0 + foreach ($e in (ConvertFrom-IntelFeed -Kind Filename -Lines $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 } + $Script:FilenameIOCs.Add([pscustomobject]@{ Pattern = $f[0]; Score = [int]$f[1]; Regex = $rx; Exclude = $fp }) + } catch { $badRegex++ } + } + $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) { + Log-Info "Intel Engine - filename IOCs left out: $lowScore scored below $($Script:Config.IntelEngine_MinFilenameScore), $badRegex not valid .NET regex" } } } else { @@ -1876,6 +2077,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 +2103,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 ('(? $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 +3752,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 +3771,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 of $($Script:IntelPathBudget), not checked" })" Log-Info " Total actions taken $($Script:Counters.ActionsTaken)" Log-Info " Failed actions $($Script:Counters.Failed)" Log-Info " IOC alerts $($Script:Counters.IOCsFound)" @@ -3757,7 +4001,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 +4044,8 @@ $jsonData = [ordered]@{ hash_iocs_loaded = $Script:HashIOCsLoaded filename_iocs = $Script:FilenameIOCsLoaded c2_iocs = $Script:C2IOCsLoaded + intel_hits = $Script:Counters.IntelHits # report-only matches, not in ioc_alerts + intel_paths_skipped = $Script:IntelPathsSkipped # paths over the per-run cap, not checked 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..8a4edfa --- /dev/null +++ b/tests/Test-IntelEngine.ps1 @@ -0,0 +1,516 @@ +<# +.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: a Low finding and + the intel_hits count, 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 what a match does. 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:IntelFindingCap .*?$' 'the intel collections and state' +$functions = foreach ($fn in 'Invoke-SafeBlock', 'ConvertFrom-IntelFeed', 'Find-IntelFilenameMatch', '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 = @{} } +} + +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. 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' +$fillers = 1..120 + +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 + '' + 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" + '' + 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' + '' + 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 Unix one +# is dropped); hashes = 3 SHA256 + 120 (MD5 and SHA1 dropped); C2 = 4 + 120. +$want = @{ Hash = 123; Filename = 125; C2 = 124 } + +# What the pre-v2026.09.25.004 parser would have cached: whole trimmed lines, +# hashes and C2 lower-cased. +function New-LegacyCache([string]$Path) { + $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() } }) + } + $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 age 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) } } + + # 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'. + $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 = { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel }; + Source = 'Cache (current)'; Counts = 'full'; Cache = 'kept'; Gets = 0 } + @{ Name = 'cache aged, one list fails'; Setup = { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel; Set-CacheAge 10; $Script:Web['hash-iocs.txt'] = $null }; + Source = 'Live (Neo23x0, 2 of 3 lists)'; Counts = 'full'; Cache = 'written'; Gets = 3 } + @{ Name = 'cache aged, all lists fail'; Setup = { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel; Set-CacheAge 10; $Script:Web.Clear() }; + Source = 'Cache (download failed)'; Counts = 'full'; Cache = 'kept'; Gets = 3 } + @{ 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 the ceiling: that list is not used, and with no cached copy of it + # the cache is not written (the next run downloads again). + @{ Name = 'oversize list'; 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 } + @{ Name = 'legacy whole-line cache'; Setup = { New-LegacyCache $cacheFile }; + Source = 'Cache (current)'; Counts = 'full'; Cache = 'kept'; Gets = 0 } + @{ 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() + 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}$' }) + $badC2 = @($Script:C2IOCs | Where-Object { -not $_ -or $_.Contains(';') -or $_.Contains(' ') }) + $badFn = @($Script:FilenameIOCs | Where-Object { -not $_.Pattern -or $_.Pattern.Contains(';') -or $_.Score -lt 60 }) + if ($badHash.Count) { Fail $label "hash entries that are not a SHA256: $($badHash[0])" } + if ($badC2.Count) { Fail $label "C2 entries that are not a bare name or address: '$($badC2[0])'" } + if ($badFn.Count) { Fail $label "filename entries with a ';' or under the minimum score: $($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 ($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 + foreach ($i in 1..25) { Add-IntelHit -Kind 'filename' -Where "C:\x\hit$i.exe" -Indicator 'p;60' -WouldHave 'kills the process' } + $af = $failures + if ($Script:Counters.IntelHits -ne 25) { Fail 'Add-IntelHit' "IntelHits = $($Script:Counters.IntelHits), expected 25" } + 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 ($failures -eq $af) { Say ' ok Add-IntelHit - counted, Low findings capped at 20, never an IOC or a High finding' 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 + if (-not @($Script:Logged | Where-Object { $_ -match 'left out: 1 scored below 60, 1 not valid \.NET regex' }).Count) { + Fail 'phase 1: counts' "expected the log to count 1 filename IOC below the minimum score and 1 invalid regex" + } else { Say ' ok phase 1: left-out filename IOCs are logged with their reasons' Green } +} 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, 'an unrelated path'), + @('', $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' })" } +} +$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: per-run cap' "the path after the cap was checked, or not counted as skipped ($($Script:IntelPathsSkipped))" } +if ($failures -eq $mf) { Say " ok matcher - full paths, case, (?i), false-positive regex, command lines, and the per-run cap" 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: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) [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:IntelPathsChecked = 0; $Script:IntelPathsSkipped = 0 + $Script:Logged.Clear(); $Script:Findings.Clear(); $Script:Actions.Clear() + $Script:Dirs = @{}; $Script:Files = @{}; $Script:Reg = @{}; $Script:Procs = @(); $Script:Hosts = @(); $Script:Dns = @() + $Script:FileHashes = @{}; $Script:HkuSids = @() +} + +# Each consumer: the world it sees, the code, the intel matches it must report, +# the actions it must take (hard-coded matches only), and its IOC count. +$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' } + ) + $Script:Cache_Processes = $Script:Procs + } + Hits = 1; 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') + } + Hits = 3; 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') + } + Hits = 1; Actions = @('delete D:\Users\bob\Downloads\toolbar-setup.exe'); Iocs = 1 } + @{ Name = 'Detection Engine'; Code = $detection + Setup = { + $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 + $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' + '0.0.0.0 sktest-c2.example # C2, blocked: not a match' + '10.0.0.5 notsktest-c2.example # a substring of a C2 name: not a match' + '10.0.0.7 intranet.corp.local' + ) + $Script:Dns = @([pscustomobject]@{ Entry = 'sktest-c2.example.'; Data = '203.0.113.7' }, + [pscustomobject]@{ Entry = 'www.microsoft.com'; Data = '23.1.2.3' }) + } + Hits = 4; Actions = @(); Iocs = 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.Hits) { + Fail $label "$($Script:Counters.IntelHits) intel matches reported, expected $($c.Hits): $((@($Script:Logged | Where-Object { $_ -like 'WARN: Intel *' })) -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.Hits -or @($intelF | Where-Object { $_.Severity -ne 'Low' }).Count) { + Fail $label "$($intelF.Count) Low intel findings, expected $($c.Hits)" + } + if ($failures -eq $before) { + Say " ok $label - $($c.Hits) 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 From e8e9771d84757158237ead26958c2411087cecde Mon Sep 17 00:00:00 2001 From: cdburgess75 <508435+cdburgess75@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:19:00 -0500 Subject: [PATCH 2/2] Intel Engine: review follow-ups - evidence in the payload, guards, cache trust From three independent reviews (PS 5.1/StrictMode, fleet safety, diff/tests): - Payload: new 'intel' object replaces intel_hits/intel_paths_skipped. The first 50 matches carry source, target, indicator, score, what a hard-coded match there would do, and a matched file's SHA256 and signer; plus paths_checked, match_seconds, regex_timeouts and list_date. The device log is not uploaded, so this is the data the next release decides on. - Guards against a bad upstream line: entries matching known-good values (core Windows binaries, the empty-file SHA256, top domains) are left out; a regex that times out is switched off; matching is capped at 30 s as well as 3,000 paths; users' folders are scanned first. - Cache: trusted only if SYSTEM or Administrators own it (a local user can create it in ProgramData) and every list still parses to 100+ entries; a future timestamp is stale; replaced only after all three lists download, so a partial refresh no longer resets its age; a failed write is logged. - C2: whole labels plus subdomains (as LOKI), IPv6 sinkholes are blocks, and the DNS check also tests the resolved data. - Process matches record 'is only reported (vendor path)' where a hard-coded match would not be killed either. - Fix: a local $score in Add-IntelHit overwrote the $Score parameter (PowerShell names are case-insensitive); caught by the new test. - Changelog claims corrected: 49 filename IOCs do score 100; the old parser could not match filenames or hashes, so the Config fix alone would have raised C2 IOCs, not kills. - Test: 17 Phase 1 scenarios, both matchers, Add-IntelHit evidence and caps, and each consumer's exact matches and actions; catches 22 of 23 mutations (the other is equivalent). Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 31 +++- CONTEXT.md | 6 +- ShellKnight.ps1 | 327 ++++++++++++++++++++++++++----------- tests/Test-IntelEngine.ps1 | 265 ++++++++++++++++++++++-------- 4 files changed, 460 insertions(+), 169 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3186dac..1febeaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,30 @@ ## [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 only ever had the ~30 names in `$Script:FallbackFolderIOCs`. The failure was one INFO line in the log: `Intel Engine skipped - The property 'IntelEngine_PrimarySource' cannot be found on this object.` 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 if the engine had loaded. The new `ConvertFrom-IntelFeed` follows each file's own header. From `hash-iocs.txt` it keeps the SHA256, lower case; 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 before it tests a line: 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. -- **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, consumers compared them with exact names (`Contains($proc.Name)`) or as escaped literal substrings, which could never match. 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 that a single match is a LOKI "notice", and no single filename IOC reaches LOKI's alert level of 100. That is 2,184 of 3,707 Windows patterns in the September 2026 list. Checks are capped at 3,000 paths per run; the log reports paths checked and seconds taken, and the payload reports `intel_paths_skipped`. -- **Every intel match is report-only (fleet safety):** this is the first time intel has loaded in the field. As written, a feed match would have killed a process outside Program Files and Windows (Process Engine), removed a Run value or deleted a startup shortcut (Persistence Engine), or deleted a file from a redirected folder (Filesystem Engine). It would also have raised an IOC: -15 points, exit code 2, and a Critical alert in Battlefield. Against the September 2026 list, 3 of a hand-picked 52 common, legitimate Windows paths match at score 60 or more, including K7's own AV binary in Program Files (`\\k7sysmon\.exe;60`). Every intel match now goes through `Add-IntelHit`. It is logged with what a hard-coded match would have done, and counted in the new payload field `intel_hits`; the first 20 per run become Low findings titled `Intel match (report-only): ...`. It is not an IOC: it is not in `ioc_alerts`, the score or the exit code, Battlefield raises no alert for it (Low severity, and the title does not start with "IOC"), and 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_hits` has been reviewed. -- **C2 matching by whole name:** the hosts file check matched C2 entries as unanchored substrings, so `earn.fm` would have matched `learn.fm`. It now compares whole names and addresses. A C2 name pointed at `0.0.0.0` or loopback is a block that a blocklist added, and is logged as such rather than reported. The DNS cache check was already exact. -- **Download and cache robustness:** the downloads 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. A list over 5 MB, or with under 100 or over 20,000 usable entries, is treated as an error page or a wrong file and not used; the cached copy stands in for it. The cache is replaced only when every list has entries, so a failed download no longer blanks a list for `CacheAgeDays`. A cache in the old whole-line format is read correctly. `IntelSource` now also reports `Live (Neo23x0, 2 of 3 lists)` and `Cache (download failed)`. The hash scan skips hashing files when no hash intel is loaded. -- **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. Its 10 scenarios cover a fresh download, CRLF and whitespace lines, a current cache, one and all lists failing, an error page, an oversize list, a legacy cache and a disabled engine. It asserts `IntelSource`, the loaded counts, the cache and `-UseBasicParsing`. It then runs every intel consumer verbatim against mocked cmdlets and asserts that an intel match is reported and not acted on, while hard-coded matches still are. 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 42 assertions, and the AST check names the line. +- **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 diff --git a/CONTEXT.md b/CONTEXT.md index d41a515..2c6f22e 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -66,11 +66,11 @@ A Finding Class is a property of the finding *type*, not of the host it was foun 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, counted in `intel_hits`, and reported as a Low finding titled +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 still an IOC and is still -acted on. +because of it. A match against ShellKnight's own hard-coded lists is an IOC, handled as before. ### Device Security Score diff --git a/ShellKnight.ps1 b/ShellKnight.ps1 index 06c9784..285f1ee 100644 --- a/ShellKnight.ps1 +++ b/ShellKnight.ps1 @@ -38,7 +38,8 @@ 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. + 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, @@ -46,24 +47,30 @@ 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). - Downloads use -UseBasicParsing (5.1's IE engine fails under - SYSTEM). A list over 5 MB, or with under 100 or over 20,000 - usable entries, is not used, and the cache is replaced only - when every list has entries. - REPORT-ONLY. An intel match (Add-IntelHit) is logged, counted in - the new payload field intel_hits, and the first 20 become Low - findings 'Intel match (report-only): ...'. It is never an IOC: - not in ioc_alerts or the score, no exit code 2, no Battlefield - alert, and nothing is killed or removed. As written, a feed match - would have killed a process outside Program Files and Windows, - removed a Run value, deleted a startup shortcut, or deleted a - file in a redirected folder. Hard-coded lists act as before. - Hosts-file C2 matching is by whole name, not substring, and a C2 - name pointed at 0.0.0.0 or loopback is a block, not a match. - Filename checks are capped at 3,000 paths per run - (intel_paths_skipped). Runtime grows by the Phase 1 download - (about 0.65 MB, weekly) and a few seconds of matching. + (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) @@ -543,10 +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 60 a single match is only a LOKI "notice"; no single - # filename IOC reaches LOKI's alert level (100). Every intel - # match is REPORT-ONLY - see changelog v2026.09.25.004. +$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, @@ -824,12 +830,20 @@ $Script:HashIOCs = (New-Object 'System.Collections.Generic.HashSet[string]' $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 paths checked per run are capped. -$Script:IntelPathBudget = 3000 -$Script:IntelPathsChecked = 0 -$Script:IntelPathsSkipped = 0 -$Script:IntelMatchClock = New-Object System.Diagnostics.Stopwatch -$Script:IntelFindingCap = 20 # Add-IntelHit: findings past this are in the log only +# 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 @@ -1240,23 +1254,50 @@ function ConvertFrom-IntelFeed { function Find-IntelFilenameMatch { param([string]$Path) if (-not $Path -or $Script:FilenameIOCs.Count -eq 0) { return $null } - if ($Script:IntelPathsChecked -ge $Script:IntelPathBudget) { $Script:IntelPathsSkipped++; 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) { - # A regex that times out (runaway backtracking) counts as no match. + if ($ioc.Off) { continue } try { if ($ioc.Regex.IsMatch($Path) -and -not ($ioc.Exclude -and $ioc.Exclude.IsMatch($Path))) { return $ioc } - } catch { } + } 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, and the first $Script:IntelFindingCap become Low -# findings. It is never an IOC: +# 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; @@ -1264,18 +1305,40 @@ function Find-IntelFilenameMatch { # 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 log shows what acting would do. +# a match from its own hard-coded list, so the data shows what acting would do. function Add-IntelHit { - param([string]$Kind, [string]$Where, [string]$Indicator, [string]$WouldHave) + 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' } - Log-Warn "Intel $Kind match ($note): $Where - indicator: $Indicator" + $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 - $Where" ` - -Action "Matched threat-intel indicator $Indicator. Not acted on: intel matches are report-only until reviewed. A single match is weak evidence; triage before acting." + 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 rest are in the run log only (lines starting "Intel").' + -Action 'The first 50 are in the report''s intel.matches; all are in the run log (lines starting "Intel").' } } @@ -1422,23 +1485,44 @@ if ($Script:Config.IntelEngine_Enabled) { $minEntries = 100 $maxEntries = 20000 - # The cached lists. They are read back through ConvertFrom-IntelFeed - # below, so a cache in the old whole-line format loads correctly too. + # 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) { - try { - $cache = Get-Content -LiteralPath $cacheFile -Raw -ErrorAction Stop | ConvertFrom-Json - foreach ($k in @('Filename', 'Hashes', 'C2')) { - if ($cache.PSObject.Properties[$k] -and $cache.$k) { $intel[$k] = @($cache.$k) } - } - $cacheDate = (Get-Item -LiteralPath $cacheFile).LastWriteTime - } catch { Log-Warn "Intel Engine - cache unreadable, ignoring it: $($_.Exception.Message)" } + $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 if ($cacheDate) { - $cacheOld = ((Get-Date) - $cacheDate).TotalDays -gt $cacheAge + # 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. @@ -1478,17 +1562,19 @@ if ($Script:Config.IntelEngine_Enabled) { } } - # Replace the cache only when every list has entries, fresh or carried - # over. Writing an empty list would hide it for CacheAgeDays; left - # unwritten, the next run downloads again. - $missing = @($sources | Where-Object { -not @($intel[$_.Kind]).Count } | ForEach-Object { $_.Name }) - if ($fresh -and -not $missing.Count) { - @{ Filename = $intel.Filename; Hashes = $intel.Hashes; C2 = $intel.C2 - Updated = (Get-Date).ToString('o'); Source = $Script:Config.IntelEngine_PrimarySource } | - ConvertTo-Json -Compress | Set-Content -LiteralPath $cacheFile -Encoding UTF8 -Force - 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) { - Log-Warn "Intel Engine - cache not written, no copy of: $($missing -join ', ')" + $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)" } @@ -1498,31 +1584,61 @@ if ($Script:Config.IntelEngine_Enabled) { $Script:Counters.IntelSource = 'Cache (current)' } + # 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. One below - # the minimum score, or that .NET cannot compile, is left out and counted. - foreach ($h in (ConvertFrom-IntelFeed -Kind Hashes -Lines $intel.Hashes)) { $null = $Script:HashIOCs.Add($h) } - foreach ($c in (ConvertFrom-IntelFeed -Kind C2 -Lines $intel.C2)) { $null = $Script:C2IOCs.Add($c) } + # 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 - foreach ($e in (ConvertFrom-IntelFeed -Kind Filename -Lines $intel.Filename)) { + $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 } - $Script:FilenameIOCs.Add([pscustomobject]@{ Pattern = $f[0]; Score = [int]$f[1]; Regex = $rx; Exclude = $fp }) - } catch { $badRegex++ } + } 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) { - Log-Info "Intel Engine - filename IOCs left out: $lowScore scored below $($Script:Config.IntelEngine_MinFilenameScore), $badRegex not valid .NET regex" + 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 { @@ -2141,8 +2257,12 @@ if ($Script:Config.ProcessEngine_Enabled) { $Script:Counters.IOCsFound++ } catch { Log-Fail "Could not kill process: $($proc.Name) - $($_.Exception.Message)" } } elseif ($procIntel) { - Add-IntelHit -Kind 'filename' -Where "process $($proc.Name) (PID $($proc.Id)) at $($procPathById[[int]$proc.Id])" ` - -Indicator "$($procIntel.Pattern) (score $($procIntel.Score))" -WouldHave 'kills the process' + # What a hard-coded match here does: killed, unless it runs from a vendor path. + $procFile = $procPathById[[int]$proc.Id] + $inVendor = $legitProcRoots | Where-Object { $procFile.StartsWith($_, [System.StringComparison]::OrdinalIgnoreCase) } + Add-IntelHit -Kind 'filename' -Source 'process' -Target "$procFile (PID $($proc.Id))" -File $procFile ` + -Indicator $procIntel.Pattern -Score $procIntel.Score ` + -WouldHave $(if ($inVendor) { 'is only reported (vendor path)' } else { 'kills the process' }) } else { Log-Info " [PROC] $($proc.Name) (PID: $($proc.Id)) CPU: $([math]::Round($proc.CPU,1))s" } @@ -2294,8 +2414,8 @@ if ($Script:Config.PersistenceEngine_Enabled) { $Script:Counters.IOCsFound++ $Script:RunKeysFound++ } elseif ($runIntel) { - Add-IntelHit -Kind 'filename' -Where "Run value $keyPath\$name = $val" ` - -Indicator "$($runIntel.Pattern) (score $($runIntel.Score))" -WouldHave 'removes the Run value' + Add-IntelHit -Kind 'filename' -Source 'Run value' -Target "$keyPath\$name = $val" ` + -Indicator $runIntel.Pattern -Score $runIntel.Score -WouldHave 'removes the Run value' } else { Log-Info " [RUN] $name = $val" } @@ -2339,8 +2459,8 @@ if ($Script:Config.PersistenceEngine_Enabled) { $Script:Counters.RunKeysRemoved++ $Script:Counters.IOCsFound++ } elseif ($userRunIntel) { - Add-IntelHit -Kind 'filename' -Where "Run value (user: $who) $keyPath\$name = $val" ` - -Indicator "$($userRunIntel.Pattern) (score $($userRunIntel.Score))" -WouldHave 'removes the Run value' + Add-IntelHit -Kind 'filename' -Source 'Run value' -Target "$keyPath\$name = $val (user: $who)" ` + -Indicator $userRunIntel.Pattern -Score $userRunIntel.Score -WouldHave 'removes the Run value' } else { Log-Info " [RUN:$who] $name = $val" } @@ -2375,8 +2495,8 @@ if ($Script:Config.PersistenceEngine_Enabled) { $lnksRemoved++ $Script:Counters.IOCsFound++ } elseif ($lnkIntel) { - Add-IntelHit -Kind 'filename' -Where "startup shortcut $($lnk.FullName)" ` - -Indicator "$($lnkIntel.Pattern) (score $($lnkIntel.Score))" -WouldHave 'deletes the shortcut' + Add-IntelHit -Kind 'filename' -Source 'startup shortcut' -Target $lnk.FullName -File $lnk.FullName ` + -Indicator $lnkIntel.Pattern -Score $lnkIntel.Score -WouldHave 'deletes the shortcut' } else { Log-Info " [LNK] $($lnk.Name)" } @@ -2747,8 +2867,8 @@ if ($Script:Config.FilesystemEngine_Enabled) { # An intel feed match is reported, not deleted (Add-IntelHit). $rdIntel = Find-IntelFilenameMatch -Path $f.FullName if ($rdIntel) { - Add-IntelHit -Kind 'filename' -Where $f.FullName ` - -Indicator "$($rdIntel.Pattern) (score $($rdIntel.Score))" -WouldHave 'deletes the file' + Add-IntelHit -Kind 'filename' -Source 'redirected folder' -Target $f.FullName -File $f.FullName ` + -Indicator $rdIntel.Pattern -Score $rdIntel.Score -WouldHave 'deletes the file' } } } @@ -2838,13 +2958,16 @@ if ($Script:Config.DetectionEngine_Enabled) { $userDirs = @(Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notmatch '^(Public|Default|All Users)$' }) + # Users' folders first: the intel filename check (capped per run) and the + # hash scan (first 100 files) are spent where downloads and droppers land, + # not on a C:\Windows\Temp that can hold thousands of files. $iocScanPaths = (New-Object 'System.Collections.Generic.List[string]') - @('C:\Users\Public','C:\Windows\Temp','C:\ProgramData') | ForEach-Object { $iocScanPaths.Add($_) } foreach ($ud in $userDirs) { + $iocScanPaths.Add((Join-Path $ud.FullName 'Downloads')) $iocScanPaths.Add((Join-Path $ud.FullName 'AppData\Local\Temp')) $iocScanPaths.Add((Join-Path $ud.FullName 'AppData\Roaming')) - $iocScanPaths.Add((Join-Path $ud.FullName 'Downloads')) } + @('C:\Users\Public','C:\ProgramData','C:\Windows\Temp') | ForEach-Object { $iocScanPaths.Add($_) } $trojanHits = 0 foreach ($scanPath in $iocScanPaths) { @@ -2863,7 +2986,8 @@ if ($Script:Config.DetectionEngine_Enabled) { foreach ($f in $files) { $fileIntel = Find-IntelFilenameMatch -Path $f.FullName if ($fileIntel) { - Add-IntelHit -Kind 'filename' -Where $f.FullName -Indicator "$($fileIntel.Pattern) (score $($fileIntel.Score))" + Add-IntelHit -Kind 'filename' -Source 'scanned file' -Target $f.FullName -File $f.FullName ` + -Indicator $fileIntel.Pattern -Score $fileIntel.Score } } } @@ -2910,7 +3034,7 @@ if ($Script:Config.DetectionEngine_Enabled) { try { $hash = (Get-FileHash -LiteralPath $f.FullName -Algorithm SHA256 -ErrorAction Stop).Hash.ToLower() if ($Script:HashIOCs.Contains($hash)) { - Add-IntelHit -Kind 'hash' -Where $f.FullName -Indicator "SHA256 $hash" + Add-IntelHit -Kind 'hash' -Source 'scanned file' -Target $f.FullName -File $f.FullName -Indicator $hash $mbHits++ } } catch { } @@ -2928,15 +3052,16 @@ if ($Script:Config.DetectionEngine_Enabled) { foreach ($line in $hostsLines) { $isWhitelisted = $Script:HostsWhitelist | Where-Object { $line -match $_ } if (-not $isWhitelisted -and $line -match '\S') { - # Whole names and addresses, not substrings: 'earn.fm' is on the C2 - # list and 'learn.fm' must not match it. (Intel: report-only.) - $hostTokens = @(($line -replace '#.*$', '').Trim() -split '\s+' | ForEach-Object { $_.ToLowerInvariant().TrimEnd('.') }) - $c2Names = @($hostTokens | Where-Object { $_ -and $Script:C2IOCs.Contains($_) }) - if ($c2Names.Count -and $hostTokens[0] -match '^(0\.0\.0\.0|127\.\d+\.\d+\.\d+|::1?)$') { + # Each address and name on the line, not the line as a substring: + # 'earn.fm' is on the C2 list and 'learn.fm' must not match it. + # Find-IntelC2Match also matches subdomains. (Intel: report-only.) + $hostTokens = @(($line -replace '#.*$', '').Trim() -split '\s+' | Where-Object { $_ }) + $c2Names = @($hostTokens | ForEach-Object { Find-IntelC2Match $_ } | Where-Object { $_ } | Select-Object -Unique) + if ($c2Names.Count -and $hostTokens[0] -match '^(0\.0\.0\.0|127\.\d+\.\d+\.\d+|::1?|0:0:0:0:0:0:0:[01])$') { # A C2 name pointed at 0.0.0.0 or loopback is BLOCKED; blocklists add these. Log-Info "Hosts file blocks C2 name(s) $($c2Names -join ', '): $line" } elseif ($c2Names.Count) { - Add-IntelHit -Kind 'C2' -Where "hosts file entry: $line" -Indicator ($c2Names -join ', ') + Add-IntelHit -Kind 'C2' -Source 'hosts file' -Target $line -Indicator ($c2Names -join ', ') $hostsHits++ } elseif ($line -notmatch '^127\.0\.0\.1\s+localhost' -and $line -notmatch '^::1') { Log-Warn "Hosts file custom entry: $line" @@ -3011,8 +3136,11 @@ if ($Script:Config.DetectionEngine_Enabled) { $dnsEntries = @(Get-DnsClientCache -ErrorAction SilentlyContinue) foreach ($entry in $dnsEntries) { $dnsName = $entry.Entry - if ($dnsName -and $Script:C2IOCs.Contains($dnsName.TrimEnd('.'))) { - Add-IntelHit -Kind 'C2' -Where "DNS cache: $dnsName resolved to $($entry.Data)" -Indicator $dnsName.TrimEnd('.') + # The name, or what it resolved to: a C2 address, or a CNAME to a C2 name. + $dnsC2 = Find-IntelC2Match $dnsName + if (-not $dnsC2) { $dnsC2 = Find-IntelC2Match ([string]$entry.Data) } + if ($dnsC2) { + Add-IntelHit -Kind 'C2' -Source 'DNS cache' -Target "$dnsName -> $($entry.Data)" -Indicator $dnsC2 $c2Hits++ } } @@ -3772,7 +3900,7 @@ 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 of $($Script:IntelPathBudget), not checked" })" +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)" @@ -4044,8 +4172,17 @@ $jsonData = [ordered]@{ hash_iocs_loaded = $Script:HashIOCsLoaded filename_iocs = $Script:FilenameIOCsLoaded c2_iocs = $Script:C2IOCsLoaded - intel_hits = $Script:Counters.IntelHits # report-only matches, not in ioc_alerts - intel_paths_skipped = $Script:IntelPathsSkipped # paths over the per-run cap, not checked + # 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 index 8a4edfa..ad2e843 100644 --- a/tests/Test-IntelEngine.ps1 +++ b/tests/Test-IntelEngine.ps1 @@ -14,8 +14,9 @@ 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: a Low finding and - the intel_hits count, never an IOC, never a kill or a removal. + 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 @@ -23,8 +24,8 @@ 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 what a match does. It does not replace - a real Windows run. + 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. @@ -50,8 +51,8 @@ function Get-Section { $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:IntelFindingCap .*?$' 'the intel collections and state' -$functions = foreach ($fn in 'Invoke-SafeBlock', 'ConvertFrom-IntelFeed', 'Find-IntelFilenameMatch', 'Add-IntelHit', 'Log-IOC') { +$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.*?' + @@ -88,6 +89,18 @@ function Invoke-WebRequest { 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 } @@ -107,14 +120,15 @@ function Fail([string]$Label, [string]$Why) { # --- 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. The -# indicators are made up; none is a real IOC. +# 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' -$fillers = 1..120 +$hashEmpty = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855' function New-Feed([string]$Kind, [string]$Nl = "`n", [int]$Fill = 120) { $lines = switch ($Kind) { @@ -131,6 +145,8 @@ function New-Feed([string]$Kind, [string]$Nl = "`n", [int]$Fill = 120) { '\\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 } } @@ -141,6 +157,7 @@ function New-Feed([string]$Kind, [string]$Nl = "`n", [int]$Fill = 120) { '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" } } @@ -152,6 +169,7 @@ function New-Feed([string]$Kind, [string]$Nl = "`n", [int]$Fill = 120) { '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 } } @@ -161,18 +179,22 @@ function New-Feed([string]$Kind, [string]$Nl = "`n", [int]$Fill = 120) { $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 Unix one -# is dropped); hashes = 3 SHA256 + 120 (MD5 and SHA1 dropped); C2 = 4 + 120. +# (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. -function New-LegacyCache([string]$Path) { +# 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 } @@ -182,7 +204,6 @@ function New-LegacyCache([string]$Path) { # 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 @@ -195,13 +216,16 @@ try { $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 age means Phase 1 did not write one; the scenario's own + # 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 } @@ -209,23 +233,40 @@ try { # 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 = { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel }; + @{ Name = 'cache current'; Setup = { New-GoodCache }; Source = 'Cache (current)'; Counts = 'full'; Cache = 'kept'; Gets = 0 } - @{ Name = 'cache aged, one list fails'; Setup = { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel; Set-CacheAge 10; $Script:Web['hash-iocs.txt'] = $null }; - Source = 'Live (Neo23x0, 2 of 3 lists)'; Counts = 'full'; Cache = 'written'; Gets = 3 } - @{ Name = 'cache aged, all lists fail'; Setup = { Set-Web; Reset-Intel; Invoke-Verbatim $phase1; Reset-Intel; Set-CacheAge 10; $Script:Web.Clear() }; + # 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 the ceiling: that list is not used, and with no cached copy of it - # the cache is not written (the next run downloads again). - @{ Name = 'oversize list'; 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 } - @{ Name = 'legacy whole-line cache'; Setup = { New-LegacyCache $cacheFile }; - Source = 'Cache (current)'; Counts = 'full'; Cache = 'kept'; Gets = 0 } + # 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 } ) @@ -239,6 +280,7 @@ try { $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 @@ -259,12 +301,12 @@ try { 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}$' }) - $badC2 = @($Script:C2IOCs | Where-Object { -not $_ -or $_.Contains(';') -or $_.Contains(' ') }) - $badFn = @($Script:FilenameIOCs | Where-Object { -not $_.Pattern -or $_.Pattern.Contains(';') -or $_.Score -lt 60 }) - if ($badHash.Count) { Fail $label "hash entries that are not a SHA256: $($badHash[0])" } - if ($badC2.Count) { Fail $label "C2 entries that are not a bare name or address: '$($badC2[0])'" } - if ($badFn.Count) { Fail $label "filename entries with a ';' or under the minimum score: $($badFn[0].Pattern)" } + $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) { @@ -277,31 +319,45 @@ try { # 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 -------------------------------------------------- + # --- Add-IntelHit: report-only ---------------------------------------------- Reset-Intel - foreach ($i in 1..25) { Add-IntelHit -Kind 'filename' -Where "C:\x\hit$i.exe" -Indicator 'p;60' -WouldHave 'kills the process' } $af = $failures - if ($Script:Counters.IntelHits -ne 25) { Fail 'Add-IntelHit' "IntelHits = $($Script:Counters.IntelHits), expected 25" } + $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 ($failures -eq $af) { Say ' ok Add-IntelHit - counted, Low findings capped at 20, never an IOC or a High finding' Green } + 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 - if (-not @($Script:Logged | Where-Object { $_ -match 'left out: 1 scored below 60, 1 not valid \.NET regex' }).Count) { - Fail 'phase 1: counts' "expected the log to count 1 filename IOC below the minimum score and 1 invalid regex" - } else { Say ' ok phase 1: left-out filename IOCs are logged with their reasons' Green } } finally { # .NET, not Remove-Item: the consumer tests below mock Remove-Item. if ([System.IO.Directory]::Exists($tmpRoot)) { [System.IO.Directory]::Delete($tmpRoot, $true) } @@ -318,7 +374,7 @@ $matchCases = @( @('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, 'an unrelated path'), + @('C:\Windows\System32\svchost.exe', $null, 'a known-good path (the over-broad entries are out)'), @('', $null, 'an empty path') ) $mf = $failures @@ -327,11 +383,44 @@ foreach ($c in $matchCases) { $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: per-run cap' "the path after the cap was checked, or not counted as skipped ($($Script:IntelPathsSkipped))" } -if ($failures -eq $mf) { Say " ok matcher - full paths, case, (?i), false-positive regex, command lines, and the per-run cap" Green } +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 @@ -345,6 +434,7 @@ $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" } @@ -387,7 +477,11 @@ function Get-PSDrive { @([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) [pscustomobject]@{ Hash = $(if ($Script:FileHashes[$LiteralPath]) { $Script:FileHashes[$LiteralPath].ToUpper() } else { 'AB' * 32 }) } } +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 } @@ -400,14 +494,25 @@ $Script:CanaryWhitelist = @() function Reset-World { Invoke-Expression $countersLit - $Script:IntelPathBudget = 3000; $Script:IntelPathsChecked = 0; $Script:IntelPathsSkipped = 0 + $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:HkuSids = @() + $Script:FileHashes = @{}; $Script:HashCalls = 0; $Script:HkuSids = @() } -# Each consumer: the world it sees, the code, the intel matches it must report, -# the actions it must take (hard-coded matches only), and its IOC count. +$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 = { @@ -415,10 +520,13 @@ $consumers = @( [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 } - Hits = 1; Actions = @('kill 4102'); Iocs = 1 } + 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' @@ -430,31 +538,52 @@ $consumers = @( $startup = 'C:\Users\bob\AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup' $Script:Files[$startup] = @('sktest-shortcut.lnk', 'Send to OneNote.lnk') } - Hits = 3; Actions = @('remove value HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run\Njrat'); Iocs = 1 } + 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') } - Hits = 1; Actions = @('delete D:\Users\bob\Downloads\toolbar-setup.exe'); Iocs = 1 } + 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 = { - $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 + & $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' - '0.0.0.0 sktest-c2.example # C2, blocked: not a match' - '10.0.0.5 notsktest-c2.example # a substring of a C2 name: not a match' + "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 = '203.0.113.7' }, - [pscustomobject]@{ Entry = 'www.microsoft.com'; Data = '23.1.2.3' }) + $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' }) } - Hits = 4; Actions = @(); Iocs = 0 } + 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) { @@ -466,20 +595,28 @@ foreach ($c in $consumers) { $skipped = @($Script:Logged | Where-Object { $_ -match ' skipped - ' }) if ($skipped.Count) { Fail $label "a block aborted: $($skipped -join ' | ')" } - if ($Script:Counters.IntelHits -ne $c.Hits) { - Fail $label "$($Script:Counters.IntelHits) intel matches reported, expected $($c.Hits): $((@($Script:Logged | Where-Object { $_ -like 'WARN: Intel *' })) -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.Hits -or @($intelF | Where-Object { $_.Severity -ne 'Low' }).Count) { - Fail $label "$($intelF.Count) Low intel findings, expected $($c.Hits)" + 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.Hits) intel match(es) reported, none acted on; hard-coded actions: $(if ($acts.Count) { $acts -join '; ' } else { 'none' })" Green + Say " ok $label - $($c.Matches.Count) intel match(es) reported, none acted on; hard-coded actions: $(if ($acts.Count) { $acts -join '; ' } else { 'none' })" Green } }