Skip to content

v2026.09.25.004: the Intel Engine loads threat intel; every intel match is report-only - #5

Open
cdburgess75 wants to merge 2 commits into
fix/os-eol-by-editionfrom
fix/intel-engine-load
Open

cdburgess75 wants to merge 2 commits into
fix/os-eol-by-editionfrom
fix/intel-engine-load

Conversation

@cdburgess75

@cdburgess75 cdburgess75 commented Sep 25, 2026 •

Copy link
Copy Markdown
Owner

Do not merge until two things are done.

  1. One real Windows run of this branch.
  2. cdburgess75/BattleField#14 deployed first. It adds the catalogue entry for the new finding title. See "What it does in Battlefield".

Merging to main puts this on every endpoint, running as SYSTEM, within about 8 hours, and it loads threat intel on the fleet for the first time. Test steps are below.

This PR is stacked on #4, which is stacked on #3, #2 and #1.

The bug (confirmed, reproduced verbatim)

The Intel Engine's Invoke-SafeBlock read $Script:Config.IntelEngine_PrimarySource in the $consolidated literal, and $Script:Config has no such property.

  • Under StrictMode 2 that throws before any download, cache write or IntelSource. With no cache written, every run takes the same path.

  • I ran the Phase 1 block verbatim under StrictMode 2 with the real Config literal. It logs exactly this, at INFO level, so it reaches the file log only:

    Intel Engine skipped - The property 'IntelEngine_PrimarySource' cannot be found on this object. Verify that the property exists.

  • The fix adds IntelEngine_PrimarySource (and the new IntelEngine_MinFilenameScore) to $Script:Config.

  • The new test also checks every $Script:Config.<Name> in the script against the Config literal via the AST. It is clean today (52 defined), and with the fix reverted it names the line.

1. What the parser kept

I read the real Neo23x0 files (September 2026). Each file's own header documents its format. LOKI's loki.py confirms the semantics: filename IOCs are compiled case-sensitive and searched in the full path, with the third field as a false-positive veto.

File Real format Old parser kept Could it match? Now kept
filename-iocs.txt REGEX;SCORE[;FP REGEX]. 3,846 data lines, of which 139 are Unix paths. The whole line, e.g. \\usbclass\.sys;80 Never. Consumers used Contains($proc.Name) against regex;score, or [regex]::Escape(line), which looks for the literal text \\usbclass\.sys;80. Pattern, score and FP regex. 3,707 Windows patterns, of which 2,184 score 60 or more.
hash-iocs.txt HASH;COMMENT or HASH;SCORE;COMMENT: 1,039 MD5, 946 SHA1, 1,260 SHA256 The whole line lower-cased, sha;comment Never. Contains($sha256) against sha;comment. SHA256 only, because the scan computes SHA256. 1,175 unique.
c2-iocs.txt A bare domain or IPv4 (1,855), 8 with ;score The whole line lower-cased Yes, and it over-matched. The DNS cache check was exact and worked. The hosts-file check was an unanchored substring test: earn.fm is listed and would have matched learn.fm, and 5.149.249.226 would have matched 15.149.249.226. Domain or IPv4. 1,792 unique.

Also found:

  • CRLF hazard: the old filter ran before Trim(). Had the file ever been served with CRLF, every blank line ("\r") would have become '', and '' escaped matches every Run value and hosts line.
  • Freshness check: raw.githubusercontent.com sends no Last-Modified header (only an ETag), so the HEAD check always falls to its catch.

2. Every consumer, and exactly what a hit does

Before this PR, "IOC" meant:

  • Log-IOC, which adds a High finding titled IOC: .... Battlefield (bf/alerts.py) raises a Critical alert for any title starting "IOC".
  • IOCsFound++, which costs 15 points (up to 50 in all), sets exit code 2, shows the "Action Required" banner, and starts a Defender quick scan.
# Where Compared A hit, with the parser fixed and nothing else A hit now
1 Process Engine, process loop FilenameIOCs.Contains($proc.Name) Stop-Process -Force plus an IOC, unless it runs under Windows or Program Files Reported. would_have: "kills the process", or "is only reported (vendor path)".
2 Persistence: HKLM/HKCU Run $val -match [regex]::Escape($ioc) Remove-ItemProperty plus an IOC Reported. would_have: "removes the Run value".
3 Persistence: HKU per-user Run same Remove-ItemProperty plus an IOC Reported.
4 Persistence: startup shortcuts Contains($lnk.BaseName) Remove-Item on the .lnk plus an IOC Reported. would_have: "deletes the shortcut".
5 Filesystem: redirected folders (non-C: Users) Contains($f.Name) Remove-Item on the user's file plus an IOC Reported. would_have: "deletes the file".
6 Detection: filename scan Contains($f.Name) IOC Reported.
7 Detection: hash scan HashIOCs.Contains($sha256) IOC Reported.
8 Detection: hosts file C2 as a substring IOC Reported. Whole labels; sinkhole entries are logged as blocks.
9 Detection: DNS cache C2 exact IOC Reported. Also subdomains, and what the name resolved to.
  • With the old parser, only rows 8 and 9 could fire, so the Config fix alone would have raised C2 IOCs and alerts.
  • With the parser fixed as well, rows 1 to 5 would have killed processes and deleted Run values, shortcuts and files, on a feed that has never run in the field.

3. Report-only

Every intel match now goes through Add-IntelHit, and nowhere else.

  • Logged and counted. The log line records what a hard-coded match in the same place would have done.
  • The first 50 go into a new payload object, intel.matches. Each has the kind, source, target, indicator, score and would_have. For a matched file it also has the SHA256 and Authenticode signer. The device log is not uploaded, so this is the data the next release decides on.
  • The first 20 become Low findings titled Intel match (report-only): <kind> in <source> - <target>.
  • Not an IOC: it does not touch IOCsFound, the score, the exit code, the banner or the Defender scan. Battlefield raises no alert for it, because it is Low and its title does not start with "IOC".
  • Nothing is killed, stopped or deleted. Hard-coded lists act exactly as before.

Against the real lists, 3 of a hand-picked 52 common Windows paths match at score 60 or more:

  • \\tmp\.exe;60 on ...\Temp\tmp.exe;
  • \\new\.exe;60 on Downloads\new.exe;
  • \\k7sysmon\.exe;60 on ...\K7 Computing\...\k7sysmon.exe, the name of a K7 antivirus component. The pattern is case-sensitive, so it matters whether the real binary is lower-case.

LOKI's own warning level is 60 (alert 100, notice 40). Below 60 a match is only a "notice", so filename IOCs below 60 are not loaded ($SK_IntelEngine_MinFilenameScore = 60). That leaves out 1,523.

4. Size caps and other guards against a bad upstream list

The feed is a third-party GitHub repository, and one bad line would reach every device.

  • Per list: over 5 MB, or with under 100 or over 20,000 usable entries, it is not used, and the cached copy stands in. Today's lists are about 3,700 / 1,300 / 1,900 entries, 0.65 MB in all.
  • Known-good screen: a filename regex that matches a core Windows binary where Windows keeps it is left out. That catches ., \\ and (?i)c:, which the fleet review showed would match every path. So are the empty-file SHA256 and top domains such as microsoft.com. None of today's entries is dropped.
  • Runaway regexes: a 250 ms match timeout, and a regex that times out once is switched off for the rest of the run. The fleet review found that one catastrophic pattern would otherwise cost about 12 minutes a run on every device, for a week.
  • Caps: 3,000 paths and 30 s of matching a run. Users' Downloads, Temp and Roaming are scanned first.
  • Cache trust: it is trusted only if SYSTEM or Administrators own it. ProgramData lets any local user create a file there first and own it, and choose the intel SYSTEM loads. It must also still parse to 100 or more entries per list. A future timestamp counts as stale. It is replaced only after all three lists download, so a partial refresh no longer resets its age. A failed write is logged.
  • Downloads: -UseBasicParsing. Without it, 5.1 hands text responses to the IE engine, which fails under SYSTEM wherever IE's first run was never completed. The progress bar is off.

What it does in Battlefield

  • Alerts: none. The findings are Low and the titles start "Intel match". ioc_alerts is unchanged by intel.
  • Customer report: merge gate, Battlefield side.
    • bf/findings.py classifies unknown titles as VULN.
    • bf/report.py groups findings by catalogue id, so all of a tenant's unclassified findings collapse into one card, titled after whichever one is seen first.
    • So Intel match (report-only): ... C:\Users\jsmith\... would appear in the Assessment Report as a weakness, and could fold unclassified High findings into a Low card.
    • Fixed in cdburgess75/BattleField#14, which must be deployed before this merges. It classifies ^Intel match \(report-only\) as a DETECTION (triage required, false positives declared), so these appear once under "Detections requiring triage", outside the risk rating and without the raw titles. It also groups unclassified findings by title.
  • New intel object: Battlefield stores the whole report (ADR 0002), so it needs no ingest change; nothing displays it yet. This query is read-only and has not been executed:
SELECT t.name AS company, d.hostname, r.run_date,
       m->>'kind' AS kind, m->>'source' AS source, m->>'target' AS target,
       m->>'indicator' AS indicator, m->>'score' AS score, m->>'would_have' AS would_have,
       m->>'signature' AS signature, m->>'signer' AS signer
FROM runs r
JOIN devices d ON d.id = r.device_ref
JOIN tenants t ON t.id = d.tenant_id
CROSS JOIN LATERAL jsonb_array_elements(r.report->'intel'->'matches') AS m
WHERE r.report->>'version' = 'v2026.09.25.004'
ORDER BY r.run_date DESC;
  • Update LATEST_SK_VERSION in battlefield.env when this ships.

Tests

New tests/Test-IntelEngine.ps1, in the style of Test-EngineScope.ps1. It runs everything verbatim from ShellKnight.ps1 under StrictMode 2, with mocked cmdlets.

  • Phase 1, 17 scenarios, with Invoke-WebRequest serving lists in the real formats:

    • a fresh download, and CRLF and whitespace lines;
    • a current, aged, future-dated, user-owned, empty, {}, corrupt, one-list-empty and legacy whole-line cache;
    • one list failing, and all lists failing with and without a cache;
    • an error page, over 20,000 entries and over 5 MB;
    • a disabled engine.

    It asserts IntelSource, the counts (non-zero where expected), what was left out and why, whether the cache was written, and -UseBasicParsing.

  • Both matchers: full paths, case, (?i), the FP regex, command lines, timeouts, both caps, and C2 labels and subdomains.

  • Add-IntelHit: it is never an IOC, the evidence is right, and the caps hold.

  • All nine consumers, run verbatim: each intel match reported (kind, source, target, would_have), each action taken, the IOC count, hosts-file blocks, and no hashing without hash intel.

  • AST check: every $Script:Config.<Name> against the Config literal.

Results:

  • 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 22. The other moves the Trim() after the comment test, and the later empty-entry guards make that change harmless.
  • Test-BiosDate, Test-DeviceIdentity, Test-EngineScope (including its child-scope AST check) and Test-OsEol still pass.
  • The parse shows 0 errors, and no non-ASCII bytes were added.
  • It is a mock test on PowerShell 7.5 (macOS), not a Windows run.

Review: three independent reviewers, covering PS 5.1/StrictMode, fleet safety, and the diff and tests. Their findings are addressed in e271743, except the Battlefield change.

  • 5.1 compatibility: nothing found. That review covered ConvertTo/From-Json on 5.1, the Regex constructor, .Content, the multi-line if assignment, and scoping.
  • Fleet safety: it confirmed that no intel match can reach an action, the IOC count, an alert or the score. It found the slow-regex, over-broad-entry and cache-poisoning risks, and the Battlefield report problem.
  • Diff and tests: it found two cache bugs, two false changelog claims and five test gaps. The new test also caught one bug of my own: a local $score overwrote the $Score parameter, since PowerShell names are case-insensitive.

Before merging: one real run

Run this in an elevated Windows PowerShell 5.1:

$f = "$env:windir\Temp\ShellKnight-test.ps1"
Invoke-RestMethod 'https://raw.githubusercontent.com/cdburgess75/ShellKnight/fix/intel-engine-load/ShellKnight.ps1' -OutFile $f
schtasks /create /tn SK-Test /tr "powershell.exe -NoProfile -ExecutionPolicy Bypass -File $f" /sc once /st 23:59 /ru SYSTEM /f
schtasks /run /tn SK-Test

When it finishes, check the newest C:\ProgramData\ShellKnight\Logs\ShellKnight_*.log:

  • It has no Intel Engine skipped, Assessment Engine skipped or Device identity skipped line.
  • It has three Intel Engine - downloaded ...: N usable entries lines.
  • Its ... hash IOCs | ... filename IOCs | ... C2 IOCs loaded line shows about 1,175 / 2,184 / 1,792.
  • Its left out: line shows 0 matching a known-good path; 0 known-good hashes or C2 entries.
  • In METRICS SUMMARY:
    • Intel source Live (Neo23x0);
    • Intel matches N;
    • Intel paths checked N in S s. S decides whether the 30 s cap suits 5.1. The PS 7 figure is about 1.3 ms a path; the 5.1 reviewer's estimate is 15 to 40 s at the 3,000-path cap.
  • For any Intel ... match WARN line, no Killed process or Removed ... line names the same thing.

Check the files as well:

  • C:\ProgramData\ShellKnight\Intel\neo23x0_consolidated.json is about 250 KB, and (Get-Acl <file>).Owner is SYSTEM or BUILTIN\Administrators.
  • In the newest C:\ProgramData\ShellKnight\JSON\*.json, intel_source is Live (Neo23x0), the three IOC counts are non-zero, and intel.match_seconds and intel.matches are present. ioc_alerts does not count intel matches.

Run it a second time: it should report Cache (current) and make no list downloads.

In Battlefield, the device should show a v2026.09.25.004 run with that intel_source. Then remove the test task with schtasks /delete /tn SK-Test /f.

Follow-ups (not in this PR)

  • Battlefield: somewhere to see intel.matches (the catalogue entry is cdburgess75/BattleField#14).
  • Criteria for acting on intel next release: decide them from intel.matches. For example, act on hash matches on unsigned files first, and filename matches only at high scores on unsigned files outside vendor paths.
  • The HEAD freshness check never works: raw GitHub sends no Last-Modified, so the lists refresh every 7 days regardless. Use the ETag, or drop the HEAD request.
  • Case-sensitive process paths: Win32_Process often reports C:\WINDOWS\..., and case-sensitive patterns miss those. This only causes misses.
  • Hard-coded Run-key and startup patterns are unanchored regexes ($name -match 'raccoon'), the same bug class as the NVIDIA incident, and a match removes the value. They are not intel, so they are unchanged here.
  • ProgramData ownership: the same risk applies to run.ps1 and config.json in C:\ProgramData\ShellKnight, and the SYSTEM task executes run.ps1. It is worth a separate look.
  • Dead code: $Script:FolderIOCs is declared and never used.

🤖 Generated with Claude Code

cdburgess75 and others added 2 commits September 25, 2026 08:49
…ch 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.<Name> via the AST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…che 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 <noreply@anthropic.com>
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant