Skip to content

v2026.09.25.001: engine results reach the payload and the score - #2

Merged
cdburgess75 merged 1 commit into
mainfrom
fix/engine-child-scope
Sep 26, 2026
Merged

cdburgess75 merged 1 commit into
mainfrom
fix/engine-child-scope

Conversation

@cdburgess75

Copy link
Copy Markdown
Owner

Do not merge until the branch has had one real Windows run. Merging to main puts this on every endpoint, running as SYSTEM, within about 8 hours. It also moves security grades across the whole fleet. Test steps and an impact query are below.

This PR is stacked on #1. Its base is fix/checkin-identity-utf8, so the diff shows only this change.

The bug

  • Invoke-SafeBlock runs its block with & $Block, which is a child scope.
  • The Assessment Engine set six variables with bare assignments: $avProduct, $edrProduct, $defStatus, $bitlockerWarn, $osEolWarn and $wuLastWarn. Each assignment made a local copy that was discarded when the block returned.
  • The payload and the scoring read the script-level defaults instead. This has been true since the first release, v1.002 (2026-05-25):
    • every device reports antivirus: "NONE DETECTED", edr: "None detected" and defender: "Unknown";
    • the BitLocker, OS end-of-life and Windows Update penalties have never applied.
  • MachineInfo is built inside the block, so it and the log had the real values all along.

Changes

  • Payload: antivirus, edr and defender now read $Script:MachineInfo[...], like every other machine field. When the engine did not run, they are null instead of a default reported as fact.
  • Warn flags: $Script:BitLockerWarn, $Script:OsEolWarn and $Script:WuLastWarn are set with $Script: inside the engine and read with it in the scoring.
  • No script-level defaults: $avProduct, $edrProduct and $defStatus are now local to the engine, so nothing outside can read a stale copy. $avProduct gets an explicit else, because an unset local would throw in the MachineInfo literal under StrictMode.
  • Defender DISABLED rule removed. See the scoring section below.
  • Persistence Engine: the same bug hit $runKeysRemoved and $policyRemoved, so "no malware Run keys found" and "no browser policy hijacks found" were logged even after a removal. These are now $Script: counters. The effect is on log text only.
  • Version v2026.09.25.001, with entries in both changelogs. The diff adds no non-ASCII bytes and the AST parse shows 0 errors.

Scoring: what changes when this ships

Rule Before After
BitLocker not on for C: never fired -15
Build past its date in the engine's EOL table never fired -20
Last Windows Update install over 30 days ago never fired -15
Defender real-time protection DISABLED never fired removed
No active AV ($Script:HasActiveAv, already $Script:) -25 -25, unchanged
  • A device can lose up to 50 points on its first run of this version. It is a measurement correction: nothing changed on the endpoints. It will look like a posture drop on the dashboard trend line and in any score shown to a customer, so annotate or explain it before a scorecard goes out.
  • Failed probes cost nothing (ADR 0009). Each flag is set only by a positive detection:
    • BitLocker counts only when a probe answered "off". If neither probe answers, bitlocker is Not available and there is no penalty.
    • EOL counts only for a build in the table.
    • Windows Update counts only when there is a history entry.
    • An engine that aborts or is disabled leaves all three off. The test covers each case.
  • Why the Defender DISABLED rule (-20) is removed instead of switched on. This is a judgement call, so tell me if you want it back.
    • Windows turns Defender off when a third-party AV registers, so on every Datto AV, Bitdefender or ESET box defender reads DISABLED. Live, the rule would take 20 points from the boxes with the most protection.
    • On a box with no AV at all, it would stack on the -25 rule and take 45.
    • The -25 rule already scores "no working AV", and scores it once.
    • Because the rule never fired, removing it changes no score anyone has seen.
  • The EOL table is lenient, so the -20 will under-fire rather than over-fire. It lists some builds as supported for years after Microsoft's end-of-servicing date:
    • 19045, Windows 10 22H2: the table says 2030-10-14; Microsoft ended it on 2025-10-14.
    • 22621 and 22631, Windows 11 22H2 and 23H2: the table gives dates later than even Microsoft's Enterprise dates (2025-10-14 and 2026-11-10).
    • As written, only these builds take the penalty today: 7601, 9200, 9600, 10240, 10586, 15063, 16299, 17134, 18362, 18363, and 19041 to 19043.
    • 19044 and 22000 flip to end-of-life on 2026-10-13 and 2026-10-14, about three weeks after this ships.
    • The table is unchanged here, and customer reports already show its os_eol strings. See the follow-ups.

What it does in Battlefield

  • Grades drop where one of the three rules applies. This feeds the D/F "needs attention" flag (fleet.py attn), the A/B compliant count, the dashboard average and the score trend.
  • The alerts queue is not affected. conditions_from in bf/alerts.py raises alerts from High findings, the IOC count, agent health and failed actions. It never reads antivirus, edr, defender or the score. The BitLocker finding was already sent (Medium) because it is added inside the engine, so no new alerts or High-finding emails come from this change.
  • The AV and EDR pills show real values instead of "none" on every device. A device whose engine did not run sends null, which the templates already show as "none" / "—".
  • Update LATEST_SK_VERSION in battlefield.env when this ships.

Predicting it from production before merging

This query is read-only and has not been executed; I had no database here. os_eol, bitlocker and last_wu_install have always been reported correctly, so the latest stored run has what it needs. The day count is as of that run, so the Windows Update column is a lower bound.

WITH latest AS (
  SELECT DISTINCT ON (r.device_ref)
         t.name AS company, d.hostname, r.security_score AS score,
         coalesce(r.report->>'os_eol', '') LIKE 'END OF LIFE%'                   AS eol,
         coalesce(r.report->>'bitlocker', '') = 'Off'                            AS bitlocker_off,
         substring(r.report->>'last_wu_install' FROM '\((\d+) days? ago\)')::int AS wu_days
  FROM runs r
  JOIN devices d ON d.id = r.device_ref
  JOIN tenants t ON t.id = d.tenant_id
  ORDER BY r.device_ref, r.run_date DESC
), grades(letter, min_score) AS (VALUES ('A', 90), ('B', 80), ('C', 70), ('D', 60), ('F', 0)),
predicted AS (
  SELECT *, greatest(0, score - CASE WHEN eol THEN 20 ELSE 0 END
                              - CASE WHEN bitlocker_off THEN 15 ELSE 0 END
                              - CASE WHEN wu_days > 30 THEN 15 ELSE 0 END) AS new_score
  FROM latest WHERE score IS NOT NULL
)
SELECT company, hostname, eol, bitlocker_off, wu_days, score, new_score,
       (SELECT letter FROM grades WHERE score     >= min_score ORDER BY min_score DESC LIMIT 1) AS grade,
       (SELECT letter FROM grades WHERE new_score >= min_score ORDER BY min_score DESC LIMIT 1) AS new_grade
FROM predicted
WHERE new_score < score
ORDER BY company, score - new_score DESC, hostname;

For a before/after grade table, group the final SELECT by grade, new_grade.

Tests

New tests/Test-EngineScope.ps1, in the style of Test-DeviceIdentity.ps1:

  • It runs all of Phase 2, the security scoring and the payload's machine fields verbatim, under StrictMode 2 with mocked Windows cmdlets.
  • It covers 13 scenarios:
    • healthy;
    • BitLocker off, through each of its two probes;
    • BitLocker unavailable;
    • an end-of-life build;
    • a stale Windows Update;
    • all three together;
    • a third-party AV with Defender off;
    • EDRs present;
    • no AV;
    • Defender off with no other AV;
    • an engine that aborts, and one that is disabled.
  • It asserts the payload's antivirus, edr, defender and bitlocker values, the exact score, and the BitLocker finding.
  • It also parses the whole script and fails if any variable set bare inside an Invoke-SafeBlock is read from somewhere that block does not enclose. That is this bug in general form, and it is how the two Persistence Engine counters were found.

Results:

  • Against v2026.09.24.001: device_id survives the engine; report sent as UTF-8 #1's head it fails 41 assertions. The scope check flags exactly the six variables and the two counters.
  • It passes on this branch.
  • Two mutations were checked and both fail the test: reverting one flag to a bare assignment, and dropping the $avProduct else.
  • Test-BiosDate and Test-DeviceIdentity still pass.
  • It is a mock test, not a Windows run.

Before merging: one real run

Run this in an elevated Windows PowerShell 5.1. Pick a machine with something to see: BitLocker off, a stale Windows Update, or a third-party AV/EDR. RLG-JANE-PC or RLG-DCFS also cover #1.

$f = "$env:windir\Temp\ShellKnight-test.ps1"
Invoke-RestMethod 'https://raw.githubusercontent.com/cdburgess75/ShellKnight/fix/engine-child-scope/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 Assessment Engine skipped and no Device identity skipped line.
  • The MACHINE INFORMATION block's Antivirus, EDR and Defender match antivirus, edr and defender in the newest C:\ProgramData\ShellKnight\JSON\*.json.
  • SECURITY GRADE reflects any BitLocker, EOL or Windows Update warning logged above it.
  • It has Battlefield push OK - run_id: <n>.

In Battlefield, the device should show a v2026.09.25.001 run with real AV and EDR pills. Then remove the test task with schtasks /delete /tn SK-Test /f.

Left alone (follow-ups)

  • The EOL table needs Microsoft's dates, per edition. Build 26100 is both Windows 11 24H2 and Server 2025; 17763 and 14393 are both LTSC and Server. Customer reports already print its os_eol strings.
  • The password-length rule reads 0 when the engine did not run. That takes -20 and raises a High finding, Password minimum length is 0 (CIS 1.1.1), which becomes a Battlefield alert. That is a collection failure scored as a vulnerability, the case ADR 0009 rules out. The test pins the current -20 for those scenarios.
  • The on-box trend tracking compares against 100. It runs before the score is computed, so the log will not report this release's drop.
  • An EDR such as SentinelOne or CrowdStrike is not credited as AV protection. On a Server, where SecurityCenter2 does not exist, a box protected by an EDR alone can take the -25. That rule is unchanged here.

🤖 Generated with Claude Code

Invoke-SafeBlock runs its block with & $Block, which is a child scope. The
Assessment Engine set $avProduct, $edrProduct, $defStatus, $bitlockerWarn,
$osEolWarn and $wuLastWarn with bare assignments, so each one made a local
that was discarded when the block returned. Since v1.002 the payload has
reported antivirus 'NONE DETECTED', edr 'None detected' and defender
'Unknown' on every device, and the BitLocker, OS EOL and Windows Update
penalties have never applied. MachineInfo, built inside the block, was right
throughout.

The payload now reads antivirus, edr and defender from MachineInfo, so they
are null when the engine did not run. The three warn flags are
$Script:-scoped. The script-level defaults are gone, and $avProduct is
assigned on every branch.

Scoring: BitLocker off (-15), an end-of-life build (-20) and no Windows
Update install in over 30 days (-15) now apply for the first time. Each flag
is set only by a positive detection. The Defender DISABLED rule (-20) is
removed rather than switched on. Live, it would take 20 from every box whose
third-party AV turns Defender off, and on a box with no AV it would count
twice alongside the -25 rule.

The same bug kept two Persistence Engine counters at zero, so their "none
found" summary lines appeared even after a removal. This is log text only.

tests/Test-EngineScope.ps1 runs Phase 2, the scoring and the payload fields
verbatim under StrictMode 2 with mocks, across 13 scenarios. It also
AST-checks the whole script for any variable set bare inside an
Invoke-SafeBlock and read outside it. It fails 41 assertions against
v2026.09.24.001 and passes here. It is a mock test, not a Windows run.

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.

@cdburgess75
cdburgess75 changed the base branch from fix/checkin-identity-utf8 to main September 26, 2026 10:28
@cdburgess75
cdburgess75 merged commit 6a140a0 into main Sep 26, 2026
3 checks passed
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