Skip to content

v2026.09.25.002: an unknown password length is not scored or reported as 0 - #3

Open
cdburgess75 wants to merge 2 commits into
fix/engine-child-scopefrom
fix/password-length-unknown
Open

cdburgess75 wants to merge 2 commits into
fix/engine-child-scopefrom
fix/password-length-unknown

Conversation

@cdburgess75

@cdburgess75 cdburgess75 commented Sep 25, 2026 •

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. Test steps are below.

This PR is stacked on #2, which is stacked on #1. Its base is fix/engine-child-scope, so the diff shows only this change.

The bug

  • $Script:MinPasswordLen started at 0 in the Phase 2 init. Only the engine's Password policy block set it, by parsing net accounts.
  • It stayed 0 whenever the length was never read:
    • the engine aborted or was disabled;
    • net accounts printed nothing;
    • net accounts printed no Minimum password length line. This is probably the case on any non-English Windows, whose net accounts output is localized.
  • Two things then treated that 0 as a real value:
    • Scoring: if ($Script:MinPasswordLen -eq 0) { -20 }.
    • CIS Benchmark: a High finding, Password minimum length is 0 (CIS 1.1.1). Battlefield's conditions_from (bf/alerts.py) raises an alert for every High finding, and bf/findings.py maps ^Password minimum length is 0\b to the VULN password-policy-blank.
  • The result is a collection failure scored and alerted as a vulnerability, which ADR 0009 rules out. v2026.09.25.001: engine results reach the payload and the score #2 flagged this as a follow-up, and its test pinned the -20.

Changes

  • $Script:MinPasswordLen starts at $null (unknown).
  • It is set only from a number actually read. The old parse was [int]($line -replace '[^\d]',''), and [int]'' is 0, so a length line with no number also read as a real 0.
  • The scoring skips a $null length.
  • CIS 1.1.1 skips a $null length and logs [CIS 1.1.1] Password minimum length: unknown - not checked.
  • The password block logs minimum length unknown ... not scored when it finds no value.
  • Both guards are needed. $null -lt 8 is $true, so without the guards an unknown length would still cost 10 points and raise a finding with a blank length. The mutation runs below show this.
  • A length that was read, including a real 0, is scored and reported exactly as before, with the same finding title Battlefield already maps.
  • New payload field password_min_length: the length as a JSON number, or null when it was not read.
    • Without it, Battlefield could not tell "unknown" from "8 or more", because neither sends a finding.
    • Battlefield's ingest requires only hostname and stores the whole report as JSONB (ADR 0002), so it accepts the field with no server change. Nothing displays it yet.
    • This is part of v2026.09.25.002, which has not shipped, so the version is not bumped again.
  • Version v2026.09.25.002, changed in the same nine places as 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

  • Scores only go up. A device whose length was not read gets back the 20 points it was losing. No device loses points.
  • Devices that really have a 0, 1-7 or 8-11 minimum still take -20, -10 or -5 and still get the High finding below 8.
  • In Battlefield:
    • sync_run_alerts resolves a finding alert as system once a later run no longer carries it. The false Password minimum length is 0 (CIS 1.1.1) alerts on these devices therefore clear on each device's first run of this version.
    • Those devices also drop password-policy-blank from the VULN-scored findings.
    • Update LATEST_SK_VERSION in battlefield.env when this ships.
  • Engine aborts are now rare, since v2026.09.24.001: device_id survives the engine; report sent as UTF-8 #1. After v2026.09.24.001: device_id survives the engine; report sent as UTF-8 #1, the remaining causes are an engine that is switched off, a localized net accounts, or net.exe failing.

Tests

tests/Test-EngineScope.ps1 from #2 is extended:

  • It also runs the CIS Benchmark block verbatim and asserts the exact CIS 1.1.1 finding title, or that there is none.
    • The harness also asserts that 1.1.1 was actually evaluated, so a CIS block that stopped early can't pass as "no finding".
  • The net mock prints realistic English net accounts output with the scenario's length.
  • The engine-aborts and engine-disabled scenarios now expect no penalty. Their machine also has a real length of 0, like their BitLocker-off and stale-WU settings: the problem is there, but it was never read.
  • New scenarios:
    • net-accounts-empty: no output. Expects 100 and no finding.
    • net-accounts-no-length: output without the length line. Expects 100 and no finding.
    • net-accounts-no-number: a length line with no number. Expects 100 and no finding.
    • password-length-0, -6 and -10: expect 80, 90 and 95. The first two also expect the finding, which for 0 has the exact title Battlefield maps.
  • Every scenario asserts password_min_length as serialized JSON: the read length as a number, or null when it was not read.

Results (20 checks: 19 scenarios plus the scope check):

  • Against v2026.09.25.001: engine results reach the payload and the score #2's head it fails 10 assertions: a score of 80 and the ...is 0 (CIS 1.1.1) finding in each of the 5 unknown-length scenarios. It passes on this branch.

  • Four mutations each fail the test:

    Mutation Failures
    Seed with 0 10
    Drop the scoring guard 5, each scoring 90
    Drop the CIS guard 5, each with a blank-length finding
    Restore the old [int] parse 6
    Remove the payload field the test stops: expected 6 payload fields
    Send the field as a string 19
    Send an unknown length as 0 5
  • Test-BiosDate and Test-DeviceIdentity still pass.

  • PSScriptAnalyzer was not run locally; CI runs it.

  • It is a mock test, not a Windows run.

Before merging: one real run

Run this in an elevated Windows PowerShell 5.1. Any machine will do, and 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/password-length-unknown/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, Device identity skipped or Password policy skipped line.
  • It has a Password policy: minimum length N line. If it says unknown instead, that box is a real example of this bug; note what net accounts prints there.
  • The [CIS 1.1.1] line agrees with it.
  • In the newest C:\ProgramData\ShellKnight\JSON\*.json:
    • password_min_length is N, as a number; null if the log said unknown.
    • findings has Password minimum length is N (CIS 1.1.1) only if N is below 8.
  • It has Battlefield push OK - run_id: <n>.

Then remove the test task with schtasks /delete /tn SK-Test /f.

The unknown path is covered by the mock test only. On an enrolled box, config.json turns the Battlefield push on, and I didn't want the test steps to push a run with the engine disabled to production.

Left alone (follow-ups)

  • Battlefield does not show password_min_length yet. A device page or compliance view could show "not collected" for null.
  • net accounts is parsed by its English label. On a localized Windows the length is now unknown rather than 0. Reading MinimumPasswordLength from secedit /export or NetUserModalsGet would work in any language.
  • The CIS block can stop at 2.9. On a box without the Defender module, Get-MpComputerStatus is not found. In pwsh 7 that is a terminating error even with -ErrorAction SilentlyContinue, and I expect the same in 5.1. The CIS block then stops there and skips its summary line. This affects log text only, and the test excludes it explicitly.
  • The script is not quite ASCII-only already. Five old comments on main contain em-dashes (lines 350, 352, 398, 2350 and 2442 on v2026.09.25.001: engine results reach the payload and the score #2's head). They are harmless in comments, and I left them alone to keep the diff focused.

🤖 Generated with Claude Code

… as 0

$Script:MinPasswordLen started at 0, and only the Assessment Engine's
'Password policy' check set it, by parsing 'net accounts'. When the engine
aborted or was disabled, or 'net accounts' gave no 'Minimum password length'
value, it stayed 0. The scoring then took 20 points and the CIS block added
the High finding "Password minimum length is 0 (CIS 1.1.1)". Battlefield
alerts on every High finding and maps that title to the VULN
password-policy-blank. That is a collection failure scored and alerted as a
vulnerability, which ADR 0009 rules out.

It now starts at $null and is set only from a number actually read ([int]''
is 0, so a length line with no number no longer parses as 0). The scoring
and CIS 1.1.1 skip a $null length, and the log says it is unknown. Both
guards are needed: $null -lt 8 is $true, so without them an unknown length
would still cost 10 points and raise a finding with a blank length. A
length that was read, including a real 0, is scored and reported exactly as
before.

tests/Test-EngineScope.ps1 now runs the CIS Benchmark block verbatim too and
asserts the CIS 1.1.1 finding. The engine-aborts and engine-disabled
scenarios no longer expect -20. New scenarios cover net accounts returning
nothing, no length line, a length line with no number, and read lengths of
0, 6 and 10. It fails 10 assertions against v2026.09.25.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.

The minimum password length read from 'net accounts', as a JSON number, or
null when it was not read. Without it Battlefield cannot tell "unknown"
from "8 or more", because neither sends a CIS 1.1.1 finding. Battlefield
stores the whole report as JSONB (ADR 0002) and requires only hostname, so
it accepts the field unchanged; nothing displays it yet.

Part of v2026.09.25.002, which has not shipped, so the version is not
bumped again; both changelog entries are extended.

tests/Test-EngineScope.ps1 asserts the field as serialized JSON in every
scenario: the read length as a number, null when unknown. Removing the
field, sending it as a string, or sending an unknown length as 0 each fails
the test.

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