diff --git a/.github/workflows/test-domain-ntlm.yml b/.github/workflows/test-domain-ntlm.yml new file mode 100644 index 00000000..aaa0b1ff --- /dev/null +++ b/.github/workflows/test-domain-ntlm.yml @@ -0,0 +1,19 @@ +name: Domain NTLM regressions +on: + push: + branches: ['**'] + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + test: + runs-on: windows-latest + strategy: + matrix: + shell: [powershell, pwsh] + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Test domain NTLM policy without changing host settings + shell: ${{ matrix.shell }} + run: ./tests/DomainNtlm.Tests.ps1 diff --git a/WELA.ps1 b/WELA.ps1 index a5e00cd6..9244b2c7 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -451,6 +451,12 @@ function AuditLogSetting { } } + $domainNtlm = Get-WelaDomainNtlmState + $auditResult += [WELA]::new( + "NTLM Authentication", "Domain NTLM auditing", $domainNtlm.Description, @(), + "Not configured", "Enable all (7) on domain controllers only", "", + "AuditNTLMInDomain; applicability is determined from Win32_OperatingSystem.ProductType." + ) $auditResult | ForEach-Object { $_.CountByLevel() } $auditResult | ForEach-Object { @@ -978,6 +984,90 @@ function UpdateRules { } } +function Get-WelaDomainNtlmState { + # ProductType distinguishes an actual DC from a member server with AD DS tools installed. + $state = [pscustomobject]@{ + Applicable = $false + Readable = $false + Value = $null + Description = 'Unknown (computer role could not be determined)' + } + try { + $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property ProductType -ErrorAction Stop + switch ($os.ProductType) { + 1 { $state.Description = 'Not applicable (Windows client)'; return $state } + 2 { $state.Applicable = $true } + 3 { $state.Description = 'Not applicable (member or standalone server, including non-DC AD CS)'; return $state } + default { return $state } + } + } catch { + $state.Description = "Unknown (computer role query failed: $($_.Exception.Message))" + return $state + } + try { + $path = 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' + $state.Description = 'Not configured' + if (Test-Path -LiteralPath $path -ErrorAction Stop) { + $properties = Get-ItemProperty -LiteralPath $path -ErrorAction Stop + $property = $properties.PSObject.Properties['AuditNTLMInDomain'] + if ($null -ne $property) { + $state.Value = $property.Value + $state.Description = switch ($state.Value) { + 0 { 'Disabled (0)' } + 7 { 'Enable all (7)' } + default { "Value $($state.Value) (not interpreted as Enable all)" } + } + } + } + $state.Readable = $true + } catch { + $state.Description = "Unknown (domain NTLM registry read failed: $($_.Exception.Message))" + } + return $state +} + +function Set-WelaDomainNtlmAudit { + [CmdletBinding(SupportsShouldProcess = $true)] + param ([switch]$Auto) + $state = Get-WelaDomainNtlmState + Write-Host "Domain NTLM auditing: $($state.Description)" + if (-not $state.Applicable) { + Write-Host '[SKIPPED] Domain NTLM policy is only changed on a confirmed domain controller.' -ForegroundColor Yellow + return + } + if (-not $state.Readable) { + throw 'Domain NTLM policy was not changed because its current state could not be read.' + } + if ($state.Value -eq 7) { + Write-Host '[SKIPPED] Domain NTLM auditing is already Enable all (7).' -ForegroundColor Yellow + return + } + $path = 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' + if (-not $PSCmdlet.ShouldProcess("$path\AuditNTLMInDomain", 'Set domain NTLM auditing to Enable all (7)')) { return } + if (-not $Auto) { + $response = Read-Host "Change domain NTLM auditing from '$($state.Description)' to 'Enable all (7)'? (Y/n)" + if ($response -ne '' -and $response -ne 'Y') { + Write-Host '[SKIPPED] Domain NTLM auditing.' -ForegroundColor Yellow + return + } + } + try { + if (-not (Test-Path -LiteralPath $path -ErrorAction Stop)) { + New-Item -Path $path -Force -ErrorAction Stop | Out-Null + } + Set-ItemProperty -LiteralPath $path -Name AuditNTLMInDomain -Value 7 -Type DWord -ErrorAction Stop + $after = Get-WelaDomainNtlmState + if (-not $after.Applicable -or -not $after.Readable -or $after.Value -ne 7) { + throw "Read-back did not confirm Enable all (7). Observed: $($after.Description)" + } + Write-Host '[OK] Domain NTLM auditing: Enable all (7), registry value verified.' -ForegroundColor Green + Write-Host 'Group Policy or MDM may reapply a different value; validate events on the domain controller.' + } catch { + throw "Domain NTLM configuration failed: $($_.Exception.Message)" + } +} + + function Set-RegistryConfig { # レジストリを変更するため -WhatIf / -Confirm に対応する [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] @@ -1294,11 +1384,12 @@ function ConfigureAuditSettings { Write-Host "" $regPaths = @( @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "RestrictSendingNTLMTraffic"; Value = 2}, - @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2}, - @{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters"; Name = "AuditNTLMInDomain"; Value = 2} + @{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2} ) Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto + Set-WelaDomainNtlmAudit -Auto:$Auto + # LDAP query logging (Directory Service EventID 1644) - domain controllers only. # "15 Field Engineering" = 5 makes expensive / inefficient LDAP searches log as 1644, which surfaces # BloodHound / SharpHound-style directory reconnaissance. Only applied where the NTDS role is present. diff --git a/tests/DomainNtlm.Tests.ps1 b/tests/DomainNtlm.Tests.ps1 new file mode 100644 index 00000000..fb57c41a --- /dev/null +++ b/tests/DomainNtlm.Tests.ps1 @@ -0,0 +1,111 @@ +# Safe unit regressions: load function definitions without dispatching WELA or changing host policy. +$ErrorActionPreference = 'Stop' +$tokens = $null +$parseErrors = $null +$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'), [ref]$tokens, [ref]$parseErrors) +if ($parseErrors.Count) { throw ($parseErrors | Out-String) } +foreach ($functionName in @('Get-WelaDomainNtlmState', 'Set-WelaDomainNtlmAudit')) { + $definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName }, $true) + if (-not $definition) { throw "Missing function $functionName" } + . ([scriptblock]::Create($definition.Extent.Text)) +} +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -cne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." } + $script:assertions++ +} +function Assert-Throws([scriptblock]$Action, [string]$Message) { + $threw = $false + try { & $Action } catch { $threw = $true } + Assert-Equal $threw $true $Message +} +function Reset-Policy($Value, $ProductType = 2) { + $script:value = $Value + $script:productType = $ProductType + $script:writes = 0 + $script:prompts = 0 + $script:reads = 0 + $script:keyExists = $true + $script:roleFails = $false + $script:readFails = $false + $script:writeFails = $false + $script:ignoreWrite = $false + $script:response = 'Y' +} +function Get-CimInstance { + param($ClassName, $Property, $ErrorAction) + if ($script:roleFails) { throw 'CIM unavailable' } + return [pscustomobject]@{ ProductType = $script:productType } +} +function Test-Path { param($LiteralPath, $ErrorAction) return $script:keyExists } +function Get-ItemProperty { + param($LiteralPath, $ErrorAction) + $script:reads++ + if ($script:readFails) { throw 'Access denied' } + if ($null -eq $script:value) { return [pscustomobject]@{} } + return [pscustomobject]@{ AuditNTLMInDomain = $script:value } +} +function New-Item { param($Path, [switch]$Force, $ErrorAction) $script:keyExists = $true } +function Set-ItemProperty { + param($LiteralPath, $Name, $Value, $Type, $ErrorAction) + if ($script:writeFails) { throw 'Access denied' } + if ($Name -ne 'AuditNTLMInDomain') { throw "Unexpected write: $Name" } + $script:writes++ + if (-not $script:ignoreWrite) { $script:value = $Value } +} +function Read-Host { param($Prompt) $script:prompts++; return $script:response } + +$script:assertions = 0 +foreach ($initial in @($null, 0, 2, 7)) { + Reset-Policy $initial + Set-WelaDomainNtlmAudit -Auto + Assert-Equal $script:value 7 "DC initial value '$initial' reaches Enable all" + $expectedWrites = if ($initial -eq 7) { 0 } else { 1 } + Assert-Equal $script:writes $expectedWrites 'Already configured DCs are idempotent' +} +Reset-Policy 2 +Assert-Equal ((Get-WelaDomainNtlmState).Description) 'Value 2 (not interpreted as Enable all)' 'Migration reports old value without inventing semantics' +foreach ($role in @(1, 3)) { + Reset-Policy 2 $role + Set-WelaDomainNtlmAudit -Auto + Assert-Equal $script:writes 0 "No domain policy writes on non-DC ProductType $role" + Assert-Equal $script:reads 0 'Non-DC domain registry is not read' + Assert-Equal ((Get-WelaDomainNtlmState).Description -like 'Not applicable*') $true 'Non-DC is reported as not applicable' + Assert-Equal $script:value 2 'Existing non-DC value is preserved' +} +foreach ($role in @($null, 0, 42)) { + Reset-Policy 2 $role + Set-WelaDomainNtlmAudit -Auto + Assert-Equal $script:writes 0 'Unknown role never receives domain policy' + Assert-Equal ((Get-WelaDomainNtlmState).Description -like 'Unknown*') $true 'Unknown role is not labeled non-DC' +} +Reset-Policy 2 +$script:roleFails = $true +Set-WelaDomainNtlmAudit -Auto +Assert-Equal $script:writes 0 'Failed role discovery never receives domain policy' +Assert-Equal ((Get-WelaDomainNtlmState).Description -like 'Unknown*query failed*') $true 'Role errors are visible' +Reset-Policy 2 +$script:readFails = $true +Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Unreadable DC policy fails visibly' +Assert-Equal $script:writes 0 'Unreadable current value is preserved' +Reset-Policy $null +$script:keyExists = $false +Set-WelaDomainNtlmAudit -Auto +Assert-Equal $script:keyExists $true 'Missing DC policy key is created' +Assert-Equal $script:value 7 'Missing DC key receives Enable all' +Reset-Policy 2 +Set-WelaDomainNtlmAudit -WhatIf +Assert-Equal $script:writes 0 'WhatIf preserves policy' +Assert-Equal $script:prompts 0 'WhatIf does not prompt without Auto' +$script:response = 'n' +Set-WelaDomainNtlmAudit +Assert-Equal $script:writes 0 'Declining preserves policy' +$script:response = '' +Set-WelaDomainNtlmAudit +Assert-Equal $script:value 7 'Confirming applies policy' +Reset-Policy 2 +$script:writeFails = $true +Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Write failure propagates' +Reset-Policy 2 +$script:ignoreWrite = $true +Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Read-back mismatch propagates' +Write-Host "PASS: $script:assertions domain NTLM assertions (mocked; no host changes)." diff --git a/website/docs/commands/usage.md b/website/docs/commands/usage.md index 7843242e..5e273ab1 100644 --- a/website/docs/commands/usage.md +++ b/website/docs/commands/usage.md @@ -36,6 +36,25 @@ Check the Windows event log file size with Yamato Security's recommendations and ## configure The `configure` command sets the recommended Windows event log audit policy and file size. +Domain NTLM auditing (`AuditNTLMInDomain`) is set to `7` (**Enable all**) only on +confirmed domain controllers. Windows clients, member/standalone servers and +non-DC AD CS servers report **Not applicable** and retain any existing value. +An unavailable or unknown computer role is reported as **Unknown** and skipped. +Role detection uses `Win32_OperatingSystem.ProductType=2`, not the presence of +AD DS tools or a registry key. Before a change, WELA reports the previous numeric +value; legacy value `2` is not described as full auditing. Changes respect the +usual confirmation prompt or `-Auto` and are verified by a registry read-back. +`audit-settings` includes role applicability and the current value in its console +and CSV output. Incoming and outgoing NTLM controls are separate from this policy. + +The [Microsoft NTLM auditing guidance](https://learn.microsoft.com/en-us/defender-for-identity/deploy/configure-windows-event-collection#configure-ntlm-auditing) +describes the policy and event collection prerequisites. A registry read-back +does not prove that events were generated, and GPO or MDM may overwrite a local +change. Validate benign domain NTLM activity and expected Operational events on +an isolated DC before deployment. `tests/DomainNtlm.Tests.ps1` uses mocked OS and +registry access; the associated Windows workflow runs Windows PowerShell 5.1 and +PowerShell 7 without changing host policy. + #### `configure` command examples Apply Yamato Security's recommended settings (with confirmation prompt before changing settings): ```