diff --git a/.github/workflows/test-domain-ntlm.yml b/.github/workflows/test-domain-ntlm.yml index facc0e50..d94dfec0 100644 --- a/.github/workflows/test-domain-ntlm.yml +++ b/.github/workflows/test-domain-ntlm.yml @@ -17,3 +17,9 @@ jobs: - name: Test domain NTLM policy in PowerShell 7 shell: pwsh run: ./tests/DomainNtlm.Tests.ps1 + - name: Test domain NTLM audit output in Windows PowerShell 5.1 + shell: powershell + run: ./tests/DomainNtlmAuditOutput.Tests.ps1 + - name: Test domain NTLM audit output in PowerShell 7 + shell: pwsh + run: ./tests/DomainNtlmAuditOutput.Tests.ps1 diff --git a/WELA.ps1 b/WELA.ps1 index 9244b2c7..0dd71dff 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -485,7 +485,14 @@ function AuditLogSetting { $disabledCount = ($_.Group | Where-Object { $notEnabled -contains $_.CurrentSetting } | ForEach-Object { $_.Rules.Count } | Measure-Object -Sum).Sum $out = "" $color = "" - if (@($_.Group | Where-Object { $_.CurrentSetting -ne "Unknown" }).Count -eq 0) { + if (@($_.Group | Where-Object { $_.Rules.Count -gt 0 }).Count -eq 0) { + # Configuration-only rows have no rule coverage to aggregate. + # Preserve their observed state, including applicability and errors. + $out = ($_.Group | Select-Object -ExpandProperty CurrentSetting -Unique) -join '; ' + if (-not $out) { $out = 'Unknown' } + $color = 'DarkYellow' + } + elseif (@($_.Group | Where-Object { $_.CurrentSetting -ne "Unknown" }).Count -eq 0) { # 設定を確認できないカテゴリ。無効と断定はできない $out = "Unknown" $color = "DarkYellow" diff --git a/tests/DomainNtlm.Tests.ps1 b/tests/DomainNtlm.Tests.ps1 index fb57c41a..719d0278 100644 --- a/tests/DomainNtlm.Tests.ps1 +++ b/tests/DomainNtlm.Tests.ps1 @@ -102,6 +102,17 @@ Assert-Equal $script:writes 0 'Declining preserves policy' $script:response = '' Set-WelaDomainNtlmAudit Assert-Equal $script:value 7 'Confirming applies policy' +foreach ($confirmation in @('y', 'Y')) { + Reset-Policy 2 + $script:response = $confirmation + Set-WelaDomainNtlmAudit + Assert-Equal $script:value 7 "Confirmation '$confirmation' applies policy" + Assert-Equal $script:writes 1 "Confirmation '$confirmation' writes once" +} +Reset-Policy 2 +$script:response = 'N' +Set-WelaDomainNtlmAudit +Assert-Equal $script:writes 0 'Uppercase refusal preserves policy' Reset-Policy 2 $script:writeFails = $true Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Write failure propagates' diff --git a/tests/DomainNtlmAuditOutput.Tests.ps1 b/tests/DomainNtlmAuditOutput.Tests.ps1 new file mode 100644 index 00000000..0b352fd5 --- /dev/null +++ b/tests/DomainNtlmAuditOutput.Tests.ps1 @@ -0,0 +1,65 @@ +# Exercise the real audit renderer/CSV exports with injected observations and rules. +# Only a temporary directory is written; no Windows policy is read or changed. +$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) } +$class = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.TypeDefinitionAst] -and $node.Name -eq 'WELA' }, $true) +. ([scriptblock]::Create($class.Extent.Text)) +$definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'AuditLogSetting' }, $true) +. ([scriptblock]::Create($definition.Extent.Text)) + +$script:assertions = 0 +function Assert-Equal($Actual, $Expected, [string]$Message) { + if ($Actual -cne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." } + $script:assertions++ +} +function TestAdministrator { return $true } +function CollectAuditpol { param([switch]$UseCached) return $true } +function GetAuditpol { return @{} } +function Get-WelaDomainNtlmState { return [pscustomobject]@{ Description = $script:description } } +function Export-MitreHeatmap { param($sigmaRules, $OutputPath, $UseIdealCount) } +function BuildAuditResult { + param($all_rules, $Baseline, $enabledguid) + $all_rules[0].applicable = $true + @( + [WELA]::new('Fixture rules', 'Available', 'Success', @($all_rules[0])) + [WELA]::new('Fixture rules', 'Unavailable', 'No Auditing', @($all_rules[1])) + ) +} + +$script:ScriptRoot = Join-Path ([IO.Path]::GetTempPath()) ('wela-domain-output-' + [guid]::NewGuid().ToString('N')) +$null = New-Item -ItemType Directory -Path $script:ScriptRoot +$script:SecurityRulesPath = Join-Path $script:ScriptRoot 'rules.json' +try { + @( + @{ id = 'available-rule'; title = 'Available fixture'; level = 'high'; subcategory_guids = @() } + @{ id = 'unavailable-rule'; title = 'Unavailable fixture'; level = 'medium'; subcategory_guids = @() } + ) | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $script:SecurityRulesPath -Encoding UTF8 + foreach ($observed in @( + 'Enable all (7)', + 'Disabled (0)', + 'Not configured', + 'Value 2 (not interpreted as Enable all)', + 'Not applicable (Windows client)', + 'Not applicable (member or standalone server, including non-DC AD CS)', + 'Unknown (computer role could not be determined)', + 'Unknown (domain NTLM registry read failed: Access denied)' + )) { + $script:description = $observed + $output = (AuditLogSetting -outType std -Baseline YamatoSecurity 6>&1 | Out-String) + $expectedHeading = 'NTLM Authentication: ' + $observed + Assert-Equal ($output -match ('(?m)^' + [regex]::Escape($expectedHeading) + '\r?$')) $true "Console heading retains '$observed'" + Assert-Equal ($output -match 'NTLM Authentication: Partially Enabled') $false 'An empty rule array does not imply partial enablement' + Assert-Equal ($output -match 'Fixture rules: Partially Enabled') $true 'Ordinary rule coverage aggregation is preserved' + $row = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'WELA-Audit-Result.csv') | Where-Object SubCategory -eq 'Domain NTLM auditing') + Assert-Equal $row.Count 1 'CSV contains one domain NTLM setting row' + Assert-Equal $row[0].CurrentSetting $observed 'CSV retains the observed configuration state' + Assert-Equal $row[0].RuleCount '0' 'Configuration row claims no detection rules' + Assert-Equal @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UsableRules.csv')).Count 1 'Configuration row does not change usable rule counts' + Assert-Equal @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UnusableRules.csv')).Count 1 'Configuration row does not change unusable rule counts' + } + Write-Host "PASS: $script:assertions domain NTLM output assertions (mocked observations; temporary CSV files only)." +} finally { + Remove-Item -LiteralPath $script:ScriptRoot -Recurse -Force +} diff --git a/website/docs/commands/usage.md b/website/docs/commands/usage.md index 5e273ab1..353b6545 100644 --- a/website/docs/commands/usage.md +++ b/website/docs/commands/usage.md @@ -55,6 +55,14 @@ an isolated DC before deployment. `tests/DomainNtlm.Tests.ps1` uses mocked OS an registry access; the associated Windows workflow runs Windows PowerShell 5.1 and PowerShell 7 without changing host policy. +Live-DC validation for [issue #363](https://github.com/Yamato-Security/WELA/issues/363) +remains pending. Before closing that issue, record the Windows build and confirmed +DC role, the previous registry value/type, the verified `AuditNTLMInDomain=7` +DWORD, and representative NTLM event XML from benign test authentication. Also +check that a second run is idempotent and that clients, member servers and non-DC CAs leave +this domain-only setting unchanged. Mocked policy tests and console/CSV regression +tests do not provide this event-generation evidence. + #### `configure` command examples Apply Yamato Security's recommended settings (with confirmation prompt before changing settings): ```