Skip to content

Self-hosted runner image has no account lockout threshold, so login lockout cannot be tested #10529

Description

@andreasjordan

What is missing

The self-hosted runner image sets no Windows account lockout threshold, so a SQL login can never be locked out on a CI runner. Nothing under .github/runners/ configures one - there is no net accounts, no secedit and no LockoutBadCount anywhere in the image build.

Without a threshold, LOGINPROPERTY(login, 'IsLocked') stays 0 no matter how many logons fail, so no test can cover unlocking a login.

Why this matters now

tests/Set-DbaLogin.Tests.ps1 contains a test that locks a login out and unlocks it three different ways. It has been skipped for years behind It -Skip "Unlock" with a note saying it "makes assumptions the password policy configuration is enabled for the Windows OS". Enabling it found a real bug in Set-DbaLogin, which is fixed in a separate PR - but that PR has to leave the test enabled and failing on CI until the runners have a threshold, because a skipped test would hide the regression again.

Which lane needs it

Only the self-hosted Azure lane. Set-DbaLogin.Tests.ps1 uses $TestConfig.InstanceSingle, so it lands in the SINGLE scenario of ci-azure.yml, where the instance is local to the runner VM.

The other lanes are not affected and cannot be:

  • integration-tests.yml (Linux) runs only .github/scripts/gh-actions.ps1, which does not touch Set-DbaLogin, and its instances are SQL Server on Linux in containers, which has no Windows account policy at all.
  • The windows-tests job in the same workflow is disabled (if: false, since 2026-07-14).

The change

One line in the image build, for example in .github/runners/image-scripts/step2-sqlinstance.ps1:

# A SQL login can only be locked out when the host has an account lockout threshold.
$null = net accounts /lockoutthreshold:5 /lockoutduration:10 /lockoutwindow:10

Only the threshold is strictly required, and it has to be 5 or lower, because the test fails exactly five logons. Duration and observation window only decide when Windows would release the lock by itself; the test unlocks explicitly, so they are there for determinism.

Two things worth checking

  1. Sysprep. step3-sysprep.ps1 runs sysprep /generalize. Please confirm the local security policy survives generalize on the resulting image - if it does not, the setting belongs in a boot-time step such as bootstrap-runner.ps1 instead.
  2. Scope. The image is shared by every runner pool, so this changes the account lockout policy for all CI runners, not just one pool. It affects local SAM accounts on the runner; the practical effect is that SQL logins with CHECK_POLICY = ON can now lock after five bad passwords.

How to verify on a runner

SQL Server reads the local policy of the machine running the instance, not the domain policy. That was confirmed in a lab where the domain policy has no threshold at all and the local one has five - the lockout follows the local value.

$login = "dbatoolsci_lockout_test"
$null = New-DbaLogin -SqlInstance $instance -Login $login -Password (ConvertTo-SecureString "password1A@" -AsPlainText -Force)
$null = Set-DbaLogin -SqlInstance $instance -Login $login -PasswordPolicyEnforced -EnableException
$badCred = New-Object System.Management.Automation.PSCredential ($login, (ConvertTo-SecureString "wrong" -AsPlainText -Force))
foreach ($attempt in 1..5) {
    # NonPooledConnection is essential: after a failed logon SqlClient blocks the pool for a growing
    # number of seconds and answers the next attempts itself, so they never reach the instance.
    try { $null = Connect-DbaInstance -SqlInstance $instance -SqlCredential $badCred -NonPooledConnection } catch { }
}
Invoke-DbaQuery -SqlInstance $instance -Query "SELECT LOGINPROPERTY('$login','BadPasswordCount') AS Bad, LOGINPROPERTY('$login','IsLocked') AS Locked"
$null = Remove-DbaLogin -SqlInstance $instance -Login $login -Force

With a threshold of five this returns Bad = 5 and Locked = 1. Today on a runner it returns Locked = 0.

🤖 Generated with Claude Code

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions