Fix audit applicability, NTLM value types, and native exit verification

This commit is contained in:
Shirofune-Security committed 2026-09-19 00:36:38 +09:00
1 parent 71215ab498
commit 7d2117ebba
14 files changed
+426 -40

No files matched your search

+12
View File
@@ -17,9 +17,21 @@ jobs:
- name: Test shared profiles in PowerShell 7
shell: pwsh
run: ./tests/audit-profiles.Tests.ps1
- name: Test profile audit output in Windows PowerShell 5.1
shell: powershell
run: ./tests/AuditProfileOutput.Tests.ps1
- name: Test profile audit output in PowerShell 7
shell: pwsh
run: ./tests/AuditProfileOutput.Tests.ps1
- name: Read all effective policies using native API in Windows PowerShell 5.1
shell: powershell
run: ./tests/audit-profiles.Windows.Tests.ps1
- name: Read all effective policies using native API in PowerShell 7
shell: pwsh
run: ./tests/audit-profiles.Windows.Tests.ps1
- name: Test native writer failure handling in Windows PowerShell 5.1
shell: powershell
run: ./tests/AuditProfileNativeWriter.Tests.ps1
- name: Test native writer failure handling in PowerShell 7
shell: pwsh
run: ./tests/AuditProfileNativeWriter.Tests.ps1
+1
View File
@@ -12,6 +12,7 @@
**バグ修正:**
- `audit-settings`でホストの役割に適用されない監査ポリシーを`Not applicable`と表示し、カテゴリの有効・無効の集計から除外するようにした。NTLMポリシーの値は、DWORD型で保存されている場合にのみ有効な設定値として解釈・検証する。 (#392) (@Shirofune-Security)
- 設定時に外部コマンドの終了コードと変更後の設定値を確認し、処理の終了前にも再確認するようにした。書き込み失敗、設定の未反映、CAサービスの再起動失敗、最終確認時の設定の不一致を明示的に報告し、一律に成功とせず、0以外の終了コードを返すようにした。 (#392) (@Shirofune-Security)
- `configure`で全てのホストに`AuditNTLMInDomain=2`を設定していた問題を修正し、ドメインコントローラと確認できたホストにのみ`7` (Enable all)を設定するようにした。その他のホストや役割を判定できないホストでは、この設定を変更しない。ドメインNTLM監査設定を明示的に表示し、設定後の値の確認とレジストリエラーの報告にも対応した。 (#389) (@Shirofune-Security)
- ルールのフィルタ条件が全て適用されず最後の条件のみが適用されていたため、ルール数が正確ではなかった。 (#358) (@fukusuket)
+1
View File
@@ -14,6 +14,7 @@
**Bug Fixes:**
- `audit-settings` now reports role-inapplicable audit policies as `Not applicable` and excludes them from category enablement totals. NTLM policy values are interpreted and verified only when stored as DWORDs. (#392) (@Shirofune-Security)
- Configuration now checks native command exit codes, verifies settings after applying changes, and checks them again before finishing. Failed writes, ineffective changes, CA restart failures and settings that no longer match at the final check produce explicit results and a nonzero exit code instead of unconditional success. (#392) (@Shirofune-Security)
- Fixed domain NTLM auditing: `configure` now sets `AuditNTLMInDomain=7` (Enable all) only on confirmed domain controllers, instead of writing `2` on every host. This setting is left unchanged on other hosts and hosts whose role cannot be determined. Audit output reports the domain NTLM setting, and configuration verifies registry writes and reports failures. (#389) (@Shirofune-Security)
- Rule filtering applied only the last criterion instead of all of them, so rule counts were inaccurate. (#358) (@fukusuket)
+56 -29
View File
@@ -41,6 +41,7 @@ class WELA {
[string] $Category
[string] $SubCategory
[string] $CurrentSetting = ""
[string] $AuditPolicyGuid = ""
[array] $Rules
[hashtable] $RulesCount
[string] $DefaultSetting = ""
@@ -456,7 +457,9 @@ function BuildAuditResult {
if ($sharedPlan) {
foreach ($policy in $sharedPlan.policies) {
$rules = ApplyRules -rules $all_rules -guid $policy.guid
$current = if ($auditpol.ContainsKey($policy.guid)) { $auditpol[$policy.guid] } else { 'Unknown' }
$current = if ($policy.mode -eq 'not-applicable') { 'Not applicable' }
elseif ($auditpol.ContainsKey($policy.guid)) { $auditpol[$policy.guid] }
else { 'Unknown' }
if ($policy.mode -ne 'not-applicable' -and $enabledguid -contains $policy.guid) {
$rules | ForEach-Object { $_.applicable = $true }
}
@@ -468,8 +471,10 @@ function BuildAuditResult {
$defaultSetting = if ($legacy) { $legacy.defaultSetting } else { '' }
$volume = if ($legacy) { $legacy.volume } else { '' }
$note = (@($policy.prerequisites, $policy.note) | Where-Object { $_ }) -join ' '
$auditResult += [WELA]::New("Security Advanced ($($policy.category))", $policy.id, $current, [array]$rules,
$entry = [WELA]::New("Security Advanced ($($policy.category))", $policy.id, $current, [array]$rules,
$defaultSetting, $policy.recommendation, $volume, $note)
$entry.AuditPolicyGuid = $policy.guid
$auditResult += $entry
}
}
@@ -540,10 +545,14 @@ function AuditLogSetting {
# ベースラインが扱っていないサブカテゴリでも、そのサブカテゴリが有効ならルールは動く。
# ルール自身が持つ subcategory_guids を見て救済する。
# A live audit mask cannot make a role-inapplicable policy produce its events.
$notApplicableGuids = @($auditResult | Where-Object {
$_.CurrentSetting -eq 'Not applicable' -and $_.AuditPolicyGuid
} | Select-Object -ExpandProperty AuditPolicyGuid)
$all_rules | ForEach-Object {
if (-not $_.applicable) {
foreach ($guid in $_.subcategory_guids) {
if ($enabledguid -contains $guid) {
if ($enabledguid -contains $guid -and $notApplicableGuids -notcontains $guid) {
$_.applicable = $true
break
}
@@ -581,18 +590,23 @@ function AuditLogSetting {
if ($outType -eq "std") {
$auditResult | Group-Object -Property Category | ForEach-Object {
$notEnabled = @("No Auditing", "Disabled", "Unknown")
$enabledCount = ($_.Group | Where-Object { $notEnabled -notcontains $_.CurrentSetting } | ForEach-Object { $_.Rules.Count } | Measure-Object -Sum).Sum
$disabledCount = ($_.Group | Where-Object { $notEnabled -contains $_.CurrentSetting } | ForEach-Object { $_.Rules.Count } | Measure-Object -Sum).Sum
$summaryRows = @($_.Group | Where-Object { $_.CurrentSetting -ne 'Not applicable' })
$enabledCount = ($summaryRows | Where-Object { $notEnabled -notcontains $_.CurrentSetting } | ForEach-Object { $_.Rules.Count } | Measure-Object -Sum).Sum
$disabledCount = ($summaryRows | Where-Object { $notEnabled -contains $_.CurrentSetting } | ForEach-Object { $_.Rules.Count } | Measure-Object -Sum).Sum
$out = ""
$color = ""
if (@($_.Group | Where-Object { $_.Rules.Count -gt 0 }).Count -eq 0) {
if ($summaryRows.Count -eq 0) {
$out = 'Not applicable'
$color = 'DarkYellow'
}
elseif (@($summaryRows | 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 '; '
$out = ($summaryRows | Select-Object -ExpandProperty CurrentSetting -Unique) -join '; '
if (-not $out) { $out = 'Unknown' }
$color = 'DarkYellow'
}
elseif (@($_.Group | Where-Object { $_.CurrentSetting -ne "Unknown" }).Count -eq 0) {
elseif (@($summaryRows | Where-Object { $_.CurrentSetting -ne "Unknown" }).Count -eq 0) {
# 設定を確認できないカテゴリ。無効と断定はできない
$out = "Unknown"
$color = "DarkYellow"
@@ -611,7 +625,7 @@ function AuditLogSetting {
$out = "Partially Enabled"
$color = "DarkYellow"
}
$enabledPercentage = "0.00%"
$enabledPercentage = ""
if ($enabledCount + $disabledCount -ne 0) {
$enabledPercentage = "({0:N2}%)" -f (($enabledCount / ($enabledCount + $disabledCount)) * 100)
}
@@ -1097,6 +1111,7 @@ function Get-WelaDomainNtlmState {
Applicable = $false
Readable = $false
Value = $null
Type = $null
Description = 'Unknown (computer role could not be determined)'
}
try {
@@ -1119,10 +1134,15 @@ function Get-WelaDomainNtlmState {
$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.Type = (Get-Item -LiteralPath $path -ErrorAction Stop).GetValueKind('AuditNTLMInDomain').ToString()
if ($state.Type -ne 'DWord') {
$state.Description = "Unknown registry type ($($state.Type)): value $($state.Value) (expected DWord)"
} else {
$state.Description = switch ($state.Value) {
0 { 'Disabled (0)' }
7 { 'Enable all (7)' }
default { "Value $($state.Value) (not interpreted as Enable all)" }
}
}
}
}
@@ -1149,7 +1169,7 @@ function Set-WelaDomainNtlmAudit {
if (-not $state.Readable) {
throw 'Domain NTLM policy was not changed because its current state could not be read.'
}
if ($state.Value -eq 7) {
if ($state.Type -eq 'DWord' -and $state.Value -eq 7) {
Write-Host '[SKIPPED] Domain NTLM auditing is already Enable all (7).' -ForegroundColor Yellow
return
}
@@ -1168,7 +1188,7 @@ function Set-WelaDomainNtlmAudit {
}
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) {
if (-not $after.Applicable -or -not $after.Readable -or $after.Type -ne 'DWord' -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
@@ -1272,6 +1292,7 @@ function Get-WelaOutgoingNtlmState {
$path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'
$name = 'RestrictSendingNTLMTraffic'
$value = $null
$type = $null
$readable = $true
$description = 'Not configured (Allow all)'
try {
@@ -1281,11 +1302,16 @@ function Get-WelaOutgoingNtlmState {
$property = $properties.PSObject.Properties[$name]
if ($null -ne $property) {
$value = $property.Value
$description = switch ($value) {
0 { 'Allow all (0)' }
1 { 'Audit all (1)' }
2 { 'Deny all (2): authentication restriction, with block events' }
default { "Unknown registry value ($value)" }
$type = (Get-Item -LiteralPath $path -ErrorAction Stop).GetValueKind($name).ToString()
if ($type -ne 'DWord') {
$description = "Unknown registry type ($type): value $value (expected DWord)"
} else {
$description = switch ($value) {
0 { 'Allow all (0)' }
1 { 'Audit all (1)' }
2 { 'Deny all (2): authentication restriction, with block events' }
default { "Unknown registry value ($value)" }
}
}
}
}
@@ -1295,6 +1321,7 @@ function Get-WelaOutgoingNtlmState {
}
[pscustomobject]@{
Value = $value
Type = $type
Readable = $readable
Description = $description
PolicySource = Get-WelaOutgoingNtlmPolicySource
@@ -1321,17 +1348,17 @@ function Set-WelaOutgoingNtlmPolicy {
if (-not $state.Readable) {
throw 'Outgoing NTLM was not changed because its current state could not be read.'
}
if ($Mode -eq 'PreserveOrAudit' -and $state.Value -eq 2) {
if ($Mode -eq 'PreserveOrAudit' -and $state.Type -eq 'DWord' -and $state.Value -eq 2) {
Write-Host '[PRESERVED] Existing Deny all enforcement. Use -OutgoingNtlmMode Audit to explicitly replace it.' -ForegroundColor Yellow
return
}
if ($Mode -eq 'PreserveOrAudit' -and $null -ne $state.Value -and $state.Value -notin @(0, 1, 2)) {
Write-Warning 'Unknown outgoing NTLM value was preserved. Select an explicit -OutgoingNtlmMode after reviewing policy.'
if ($Mode -eq 'PreserveOrAudit' -and $null -ne $state.Type -and ($state.Type -ne 'DWord' -or $state.Value -notin @(0, 1, 2))) {
Write-Warning 'Unknown outgoing NTLM value/type was preserved. Select an explicit -OutgoingNtlmMode after reviewing policy.'
return
}
$desired = if ($Mode -eq 'Deny') { 2 } else { 1 }
$description = if ($desired -eq 2) { 'Deny all (2): restrict outgoing NTLM authentication' } else { 'Audit all (1): log outgoing NTLM without denying it' }
if ($state.Value -eq $desired) {
if ($state.Type -eq 'DWord' -and $state.Value -eq $desired) {
Write-Host "[SKIPPED] Outgoing NTLM is already $description." -ForegroundColor Yellow
return
}
@@ -1353,15 +1380,15 @@ function Set-WelaOutgoingNtlmPolicy {
if (-not $freshState.Readable) {
throw 'Outgoing NTLM was not changed because its current state became unreadable.'
}
if ($Mode -eq 'PreserveOrAudit' -and $freshState.Value -eq 2) {
if ($Mode -eq 'PreserveOrAudit' -and $freshState.Type -eq 'DWord' -and $freshState.Value -eq 2) {
Write-Host '[PRESERVED] Deny all enforcement appeared before the write. Select explicit Audit mode to replace it.' -ForegroundColor Yellow
return
}
if ($Mode -eq 'PreserveOrAudit' -and $null -ne $freshState.Value -and $freshState.Value -notin @(0, 1, 2)) {
Write-Warning "Outgoing NTLM changed to an unknown value ($($freshState.Value)); it was preserved."
if ($Mode -eq 'PreserveOrAudit' -and $null -ne $freshState.Type -and ($freshState.Type -ne 'DWord' -or $freshState.Value -notin @(0, 1, 2))) {
Write-Warning "Outgoing NTLM changed to an unknown value/type ($($freshState.Value)/$($freshState.Type)); it was preserved."
return
}
if ($freshState.Value -eq $desired) {
if ($freshState.Type -eq 'DWord' -and $freshState.Value -eq $desired) {
Write-Host "[SKIPPED] Outgoing NTLM is now already $description." -ForegroundColor Yellow
return
}
@@ -1370,7 +1397,7 @@ function Set-WelaOutgoingNtlmPolicy {
}
Set-ItemProperty -LiteralPath $path -Name $name -Value $desired -Type DWord -ErrorAction Stop
$after = Get-WelaOutgoingNtlmState
if (-not $after.Readable -or $after.Value -ne $desired) {
if (-not $after.Readable -or $after.Type -ne 'DWord' -or $after.Value -ne $desired) {
throw "Read-back did not match requested value $desired. Observed: $($after.Description)"
}
Write-Host "[OK] Outgoing NTLM: $($after.Description)" -ForegroundColor Green
+10 -2
View File
@@ -203,8 +203,16 @@ function Set-WelaEffectiveAuditPolicy {
)
if ($Mode -eq 'minimum' -and $Mask -eq 0) { return }
$arguments = @(Get-WelaAuditSetArguments -Guid $Guid -Mask $Mask -Mode $Mode)
$output = & auditpol.exe @arguments 2>&1
if ($LASTEXITCODE -ne 0) { throw "auditpol /set failed ($LASTEXITCODE): $($output -join ' ')" }
$command = Get-Command -Name 'auditpol.exe' -CommandType Application -ErrorAction Stop
# Native stderr alone is not failure, including under Windows PowerShell 5.1.
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$global:LASTEXITCODE = $null
$output = @(& $command.Source @arguments 2>&1)
$exitCode = $global:LASTEXITCODE # Snapshot before formatting diagnostics or running another command.
if ($null -eq $exitCode -or $exitCode -ne 0) {
throw "auditpol /set failed ($exitCode): $($output -join ' ')"
}
}
function Get-WelaHostContext {
+3 -3
View File
@@ -370,10 +370,10 @@ function Set-WelaNtlmConfigurationControl {
$skipReason = $state.Description
} elseif (-not $state.Readable) {
throw "$Scope NTLM current state could not be read: $($state.Description)"
} elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $state.Value -eq 2) {
} elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $state.Type -eq 'DWord' -and $state.Value -eq 2) {
$skipReason = 'Preserved existing Deny all enforcement (2); use -OutgoingNtlmMode Audit to explicitly replace it.'
} elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $null -ne $state.Value -and $state.Value -notin @(0, 1, 2)) {
$skipReason = "Preserved unknown outgoing NTLM value ($($state.Value)); select an explicit mode after policy review."
} elseif ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit' -and $null -ne $state.Type -and ($state.Type -ne 'DWord' -or $state.Value -notin @(0, 1, 2))) {
$skipReason = "Preserved unknown outgoing NTLM value/type ($($state.Value)/$($state.Type)); select an explicit mode after policy review."
}
if (-not $skipReason) {
if ($Scope -eq 'Outgoing' -and $Mode -eq 'Deny') {
+79
View File
@@ -0,0 +1,79 @@
# Executes only the exported writer's function definition with safe resolver and
# argument-builder fixtures. Native children emit diagnostics and exit; no auditpol
# command or Windows policy mutation is ever invoked.
$ErrorActionPreference = 'Stop'
$tokens = $null; $errors = $null
$path = Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1'
$ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw ($errors | Out-String) }
$definition = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Set-WelaEffectiveAuditPolicy' }, $true)
if (-not $definition) { throw 'Native audit writer definition was not found.' }
. ([scriptblock]::Create($definition.Extent.Text))
Set-StrictMode -Version 2.0
$engine = (Get-Command -Name (Get-Process -Id $PID).Path -CommandType Application -ErrorAction Stop).Source
$guid = '0CCE922B-69AE-11D9-BED3-505054503030'
$script:assertions = 0; $script:lookups = 0
function Assert($Condition, [string]$Message) {
if (-not $Condition) { throw "FAIL: $Message" }
$script:assertions++
}
function Invoke-ExpectFailure([scriptblock]$Action, [string]$Pattern) {
$caught = ''
try { & $Action } catch { $caught = $_.ToString() }
Assert ($caught -match $Pattern) "Expected '$Pattern'; observed '$caught'"
return $caught
}
function Get-Command {
param($Name, $CommandType, $ErrorAction)
$script:lookups++
if ($Name -ne 'auditpol.exe' -or $CommandType -ne 'Application' -or $ErrorAction -ne 'Stop') {
throw 'Writer must resolve the auditpol application with a terminating lookup.'
}
if ($script:lookupFails) { throw 'Injected auditpol lookup failure' }
[pscustomobject]@{ Source = $script:resolvedSource }
}
function Get-WelaAuditSetArguments {
param($Guid, $Mask, $Mode)
return $script:nativeArguments
}
$script:lookupFails = $true
$script:resolvedSource = $engine
$script:nativeArguments = @('-NoProfile', '-Command', 'exit 0')
$global:LASTEXITCODE = 0
$null = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'Injected auditpol lookup failure'
Assert ($script:lookups -eq 1) 'A stale native zero cannot bypass a failed executable lookup'
$script:lookupFails = $false
$script:resolvedSource = Join-Path ([IO.Path]::GetTempPath()) ('wela-missing-native-' + [guid]::NewGuid().ToString('N') + '.exe')
$global:LASTEXITCODE = 0
$null = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'not recognized|failed'
Assert ($null -eq $global:LASTEXITCODE) 'An executable disappearing after lookup cannot retain an earlier success code'
$script:resolvedSource = $engine
$script:nativeArguments = @('-NoProfile', '-Command', "[Console]::Error.WriteLine('writer native failure diagnostic'); exit 7")
$global:LASTEXITCODE = 0
$caught = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'failed \(7\)'
Assert ($caught -match 'writer native failure diagnostic') 'Native exit failure retains stderr diagnostics'
Assert ($global:LASTEXITCODE -eq 7) 'The newly executed process exit code is observed'
$script:nativeArguments = @('-NoProfile', '-Command', "[Console]::Error.WriteLine('non-fatal native diagnostic'); exit 0")
$global:LASTEXITCODE = 7
$PSNativeCommandUseErrorActionPreference = $true
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3
Assert ($global:LASTEXITCODE -eq 0) 'A fresh zero succeeds even with stderr and a previous failure'
Assert ($ErrorActionPreference -eq 'Stop' -and $PSNativeCommandUseErrorActionPreference) 'Native preferences remain local to the writer'
# A malformed/inert executable fixture returns without updating a process exit code.
# This proves absence of a new code cannot be mistaken for the previous zero.
$script:resolvedSource = { 'Fixture produced no native exit status' }
$script:nativeArguments = @()
$global:LASTEXITCODE = 0
$null = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'failed \(\)'
Assert ($null -eq $global:LASTEXITCODE) 'Missing new native exit status is rejected'
$script:lookupFails = $true
$before = $script:lookups
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode minimum
Assert ($script:lookups -eq $before) 'An empty minimum policy does not resolve or execute a writer'
$global:LASTEXITCODE = 0 # Expected fixture failures must not fail the CI shell wrapper.
Write-Host "PASS: $script:assertions native audit writer assertions (safe native children; no policy changes)."
+113
View File
@@ -0,0 +1,113 @@
# Exercise the real profile, audit renderer, rule coverage and CSV output with
# injected audit observations. Only temporary files are written; no Windows policy changes.
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
$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))
foreach ($name in @('ApplyRules', 'BuildAuditResult', 'AuditLogSetting')) {
$definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $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 $script:observedAudit }
function Get-WelaSelectedContext { return [pscustomobject]@{ Role = $script:observedRole; Build = 26100 } }
function GetBaselineConfig {
# Advanced audit policies still come from the actual versioned profile.
return [pscustomobject]@{ baselines = [pscustomobject]@{ YamatoSecurity = [pscustomobject]@{} }; catalog = @() }
}
function Get-WelaOutgoingNtlmState { return [pscustomobject]@{ Description = 'Audit all (1)'; PolicySource = 'Test observation' } }
function Get-WelaDomainNtlmState { return [pscustomobject]@{ Description = 'Test observation' } }
function Export-MitreHeatmap {
param($sigmaRules, $OutputPath, $UseIdealCount)
$script:heatmapRules = @($sigmaRules)
}
$catalog = (Import-WelaAuditProfiles).catalog
$guids = @{}
foreach ($policy in $catalog) { $guids[$policy.id] = $policy.guid }
$fallbackGuid = '00000000-0000-0000-0000-000000000001'
$script:ScriptRoot = Join-Path ([IO.Path]::GetTempPath()) ('wela-profile-output-' + [guid]::NewGuid().ToString('N'))
$null = New-Item -ItemType Directory -Path $script:ScriptRoot
$script:SecurityRulesPath = Join-Path $script:ScriptRoot 'rules.json'
try {
@(
@{ id = 'directory'; title = 'DC-only rule'; level = 'high'; subcategory_guids = @($guids['Directory Service Changes']) }
@{ id = 'kerberos'; title = 'DC-only rule in a mixed category'; level = 'high'; subcategory_guids = @($guids['Kerberos Authentication Service']) }
@{ id = 'credential'; title = 'Disabled rule in a mixed category'; level = 'medium'; subcategory_guids = @($guids['Credential Validation']) }
@{ id = 'ca'; title = 'CA-only rule'; level = 'medium'; subcategory_guids = @($guids['Certification Services']) }
@{ id = 'kernel'; title = 'Enabled applicable rule'; level = 'medium'; subcategory_guids = @($guids['Kernel Object']) }
@{ id = 'alternative'; title = 'Applicable alternative log source'; level = 'medium'; subcategory_guids = @($guids['Directory Service Changes'], $guids['Kernel Object']) }
@{ id = 'fallback'; title = 'Enabled policy outside the catalog'; level = 'low'; subcategory_guids = @($fallbackGuid) }
@{ id = 'unknown'; title = 'Uncategorized rule'; level = 'low'; subcategory_guids = @() }
) | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $script:SecurityRulesPath -Encoding UTF8
foreach ($role in @('Client', 'MemberServer', 'DomainController', 'ADCS')) {
foreach ($observed in @('Success', 'No Auditing', 'Missing')) {
$script:observedRole = $role
$script:observedAudit = @{}
foreach ($policy in $catalog) { $script:observedAudit[$policy.guid] = 'No Auditing' }
foreach ($name in @('Directory Service Changes', 'Kerberos Authentication Service', 'Certification Services')) {
if ($observed -eq 'Missing') { $script:observedAudit.Remove($guids[$name]) }
else { $script:observedAudit[$guids[$name]] = $observed }
}
$script:observedAudit[$guids['Kernel Object']] = 'Success'
$script:observedAudit[$fallbackGuid] = 'Success'
$output = AuditLogSetting -outType std -Baseline YamatoSecurity 6>&1 | Out-String
$rows = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'WELA-Audit-Result.csv'))
foreach ($name in @('Directory Service Changes', 'Kerberos Authentication Service', 'Certification Services')) {
$policy = $catalog | Where-Object id -eq $name
$row = @($rows | Where-Object SubCategory -eq $name)
$expectedState = if ($role -notin $policy.roles) { 'Not applicable' }
elseif ($observed -eq 'Missing') { 'Unknown' }
else { $observed }
Assert-Equal $row.Count 1 "$role/$observed contains exactly one $name CSV row"
Assert-Equal $row[0].CurrentSetting $expectedState "$role/$observed $name uses role applicability before the live state"
$expectedRuleCount = if ($name -eq 'Directory Service Changes') { '2' } else { '1' }
Assert-Equal $row[0].RuleCount $expectedRuleCount "$role/$observed retains mapped rules for $name without dropping them from the corpus"
}
if ($role -ne 'DomainController') {
Assert-Equal ($output -match '(?m)^Security Advanced \(DS Access\): Not applicable\r?$') $true "$role/$observed all-inapplicable category has no enabled percentage"
Assert-Equal ($output -match '(?m)^Security Advanced \(Account Logon\): Disabled\(0[.,]00%\)\r?$') $true "$role/$observed excludes DC-only rows from mixed category totals"
}
if ($role -ne 'ADCS') {
Assert-Equal ($output -match '(?m)^Security Advanced \(Object Access\): Enabled\(100[.,]00%\)\r?$') $true "$role/$observed excludes the CA-only row from enabled category coverage"
}
$usable = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UsableRules.csv'))
$unusable = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UnusableRules.csv'))
$expectedUsable = 3
if ($observed -eq 'Success' -and $role -eq 'DomainController') { $expectedUsable += 2 }
if ($observed -eq 'Success' -and $role -eq 'ADCS') { $expectedUsable++ }
Assert-Equal $usable.Count $expectedUsable "$role/$observed does not rescue role-inapplicable GUIDs as usable"
Assert-Equal ($usable.Count + $unusable.Count) 8 "$role/$observed retains all unique rules in the utilization denominator"
Assert-Equal ($usable.id -contains 'alternative') $true "$role/$observed permits an applicable alternative source"
Assert-Equal ($usable.id -contains 'fallback') $true "$role/$observed still rescues an enabled GUID outside the catalog"
Assert-Equal ($usable.id -contains 'unknown') $false "$role/$observed leaves an unknown source unavailable"
$expectedUtilization = 'You can utilize {0:N2}% of your detection rules.' -f ($expectedUsable / 8 * 100)
Assert-Equal ($output.Contains($expectedUtilization)) $true "$role/$observed reports utilization from the complete deduplicated corpus"
foreach ($ruleId in @('directory', 'kerberos', 'ca')) {
$rule = $script:heatmapRules | Where-Object id -eq $ruleId
$applicableRole = if ($ruleId -eq 'ca') { 'ADCS' } else { 'DomainController' }
if ($role -ne $applicableRole) {
Assert-Equal $rule.applicable $false "$role/$observed excludes $ruleId from current heatmap coverage"
Assert-Equal $rule.ideal $false "$role/$observed excludes $ruleId from ideal heatmap coverage"
}
}
}
}
Write-Host "PASS: $script:assertions audit profile output assertions (mocked observations; temporary CSV files only)."
} finally {
Remove-Item -LiteralPath $script:ScriptRoot -Recurse -Force
}
+37 -2
View File
@@ -18,8 +18,9 @@ function Assert-Throws([scriptblock]$Action, [string]$Message) {
try { & $Action } catch { $threw = $true }
Assert-Equal $threw $true $Message
}
function Reset-Policy($Value, $ProductType = 2) {
function Reset-Policy($Value, $ProductType = 2, [string]$Type = 'DWord') {
$script:value = $Value
$script:type = $Type
$script:productType = $ProductType
$script:writes = 0
$script:prompts = 0
@@ -27,8 +28,10 @@ function Reset-Policy($Value, $ProductType = 2) {
$script:keyExists = $true
$script:roleFails = $false
$script:readFails = $false
$script:typeReadFails = $false
$script:writeFails = $false
$script:ignoreWrite = $false
$script:ignoreTypeWrite = $false
$script:response = 'Y'
}
function Get-CimInstance {
@@ -44,13 +47,26 @@ function Get-ItemProperty {
if ($null -eq $script:value) { return [pscustomobject]@{} }
return [pscustomobject]@{ AuditNTLMInDomain = $script:value }
}
function Get-Item {
param($LiteralPath, $ErrorAction)
$key = [pscustomobject]@{}
$key | Add-Member ScriptMethod GetValueKind {
param($Name)
if ($script:typeReadFails) { throw 'Value kind unavailable' }
return [Microsoft.Win32.RegistryValueKind]$script:type
}
return $key
}
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 }
if (-not $script:ignoreWrite) {
$script:value = $Value
if (-not $script:ignoreTypeWrite) { $script:type = $Type }
}
}
function Read-Host { param($Prompt) $script:prompts++; return $script:response }
@@ -119,4 +135,23 @@ Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Write failure propagates'
Reset-Policy 2
$script:ignoreWrite = $true
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Read-back mismatch propagates'
foreach ($kind in @('String', 'QWord')) {
Reset-Policy '7' 2 $kind
$state = Get-WelaDomainNtlmState
Assert-Equal $state.Type $kind 'Domain state retains registry kind'
Assert-Equal ($state.Description -like 'Unknown registry type*expected DWord*') $true 'A non-DWORD 7 is not reported as Enable all'
Set-WelaDomainNtlmAudit -Auto
Assert-Equal $script:writes 1 'A numerically matching value with the wrong type is repaired'
Assert-Equal $script:type 'DWord' 'Domain repair writes DWORD'
Assert-Equal ((Get-WelaDomainNtlmState).Description) 'Enable all (7)' 'Only the repaired DWORD is reported as Enable all'
}
Reset-Policy '7' 2 'String'
$script:ignoreTypeWrite = $true
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Domain read-back rejects the right value with the wrong type'
Assert-Equal $script:writes 1 'Domain read-back type failure occurs after an attempted repair'
Reset-Policy 7
$script:typeReadFails = $true
Assert-Equal ((Get-WelaDomainNtlmState).Readable) $false 'A registry kind read failure is not a readable domain state'
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Domain configuration fails closed when registry kind cannot be read'
Assert-Equal $script:writes 0 'Unknown domain registry kind is never overwritten'
Write-Host "PASS: $script:assertions domain NTLM assertions (mocked; no host changes)."
+55 -1
View File
@@ -27,6 +27,8 @@ function New-TestContext([switch]$DryRun) {
}
function Reset-Mocks($Outgoing = 0, $Domain = 2, $ProductType = 2) {
$script:registry = @{ RestrictSendingNTLMTraffic = $Outgoing; AuditNTLMInDomain = $Domain }
$script:registryTypes = @{ RestrictSendingNTLMTraffic = 'DWord'; AuditNTLMInDomain = 'DWord' }
$script:typeReadFails = $false; $script:ignoreTypeWrite = $false
$script:productType = $ProductType
$script:writes = 0; $script:readFails = $false; $script:writeFails = ''
$script:roleFails = $false
@@ -49,10 +51,20 @@ function Get-ItemProperty {
if ($script:readFails) { throw 'Mock registry read failure' }
return [pscustomobject]$script:registry
}
function Get-Item {
param($LiteralPath, $ErrorAction)
$key = [pscustomobject]@{}
$key | Add-Member ScriptMethod GetValueKind {
param($Name)
if ($script:typeReadFails) { throw 'Mock registry kind read failure' }
return [Microsoft.Win32.RegistryValueKind]$script:registryTypes[$Name]
}
return $key
}
function Get-WelaRegistryState {
param($Path, $Name)
if ($script:readFails) { throw 'Mock registry read failure' }
[pscustomobject]@{ KeyExists = $true; ValueExists = ($null -ne $script:registry[$Name]); Value = $script:registry[$Name]; Type = 'DWord' }
[pscustomobject]@{ KeyExists = $true; ValueExists = ($null -ne $script:registry[$Name]); Value = $script:registry[$Name]; Type = $script:registryTypes[$Name] }
}
function Set-ItemProperty {
param($LiteralPath, $Name, $Value, $Type, $ErrorAction)
@@ -64,6 +76,7 @@ function Set-ItemProperty {
if ($script:writeFails -eq $Name) { throw 'Mock NTLM write failure' }
$script:writes++
$script:registry[$Name] = $Value
if (-not $script:ignoreTypeWrite) { $script:registryTypes[$Name] = $Type }
}
try {
Reset-Mocks
@@ -145,6 +158,47 @@ try {
Set-WelaOutgoingNtlmPolicy -Context $context -WhatIf
Set-WelaDomainNtlmAudit -Context $context -WhatIf
Assert ($script:writes -eq 0) 'Context adapters also preserve standalone WhatIf behavior'
foreach ($value in @('0', '1', '2')) {
Reset-Mocks $value
$script:registryTypes.RestrictSendingNTLMTraffic = 'String'
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context
$row = $context.Results[0]
Assert ($script:writes -eq 0 -and $row.Status -eq 'Skipped') 'Integrated default preserves numeric strings'
Assert ($row.Diagnostic -match 'unknown.*value/type' -and $row.Before.Type -eq 'String') 'Integrated early decision records unknown type without mislabeling string 2 as enforcement'
}
foreach ($mode in @('Audit', 'Deny')) {
$desired = if ($mode -eq 'Audit') { 1 } else { 2 }
Reset-Mocks ([string]$desired) '7'
$script:registryTypes.RestrictSendingNTLMTraffic = 'String'
$script:registryTypes.AuditNTLMInDomain = 'String'
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context -Mode $mode
Set-WelaDomainNtlmAudit -Context $context
$result = Complete-WelaConfiguration $context
Assert ($result.ExitCode -eq 0 -and $script:writes -eq 2) 'Explicit outgoing and domain configuration repair matching numeric strings'
Assert ($script:registryTypes.RestrictSendingNTLMTraffic -eq 'DWord' -and $script:registryTypes.AuditNTLMInDomain -eq 'DWord') 'Both integrated repairs verify DWORD types'
$journal = @(Get-Content -LiteralPath (Join-Path $context.BackupPath 'before.jsonl') | ConvertFrom-Json)
Assert ($journal.Count -eq 2 -and $journal[0].Before.Type -eq 'String' -and $journal[1].Before.Type -eq 'String') 'Type repairs journal original string types for recovery'
}
Reset-Mocks '1' '7'
$script:registryTypes.RestrictSendingNTLMTraffic = 'String'
$script:registryTypes.AuditNTLMInDomain = 'String'
$script:ignoreTypeWrite = $true
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context -Mode Audit
Set-WelaDomainNtlmAudit -Context $context
$result = Complete-WelaConfiguration $context
Assert ($script:writes -eq 2 -and $result.Failed -eq 2 -and $result.ExitCode -eq 1) 'Integrated read-back rejects numeric matches with unchanged invalid types'
Reset-Mocks 1 7
$script:typeReadFails = $true
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context -Mode Audit
Set-WelaDomainNtlmAudit -Context $context
$result = Complete-WelaConfiguration $context
Assert ($script:writes -eq 0 -and $result.Failed -eq 2 -and $result.ExitCode -eq 1) 'Unreadable registry kinds fail closed before integrated writes'
Write-Host "PASS: $script:assertions NTLM integration assertions (mocked; no Windows changes)."
} finally {
foreach ($context in $script:contexts) {
+1 -1
View File
@@ -58,7 +58,7 @@ function New-RaceContext([switch]$Prompt) {
}
function Get-WelaOutgoingNtlmState {
# The first display is deliberately stale; the shared runner must trust its own fresh read.
[pscustomobject]@{ Readable = $true; Value = 0; Description = 'Allow all (initial read)'; PolicySource = 'mock' }
[pscustomobject]@{ Readable = $true; Value = 0; Type = 'DWord'; Description = 'Allow all (initial read)'; PolicySource = 'mock' }
}
function Get-WelaRegistryState {
param($Path, $Name)
+56 -2
View File
@@ -19,14 +19,17 @@ function Assert-Throws([scriptblock]$Action, [string]$Message) {
try { & $Action } catch { $threw = $true }
Assert-Equal $threw $true $Message
}
function Reset-Policy($Value) {
function Reset-Policy($Value, [string]$Type = 'DWord') {
$script:value = $Value
$script:type = $Type
$script:keyExists = $true
$script:writes = 0
$script:prompts = 0
$script:readFails = $false
$script:typeReadFails = $false
$script:writeFails = $false
$script:ignoreWrite = $false
$script:ignoreTypeWrite = $false
$script:response = 'Y'
$script:rsop = @()
$script:onPrompt = $null
@@ -42,12 +45,25 @@ function Get-ItemProperty {
if ($null -eq $script:value) { return [pscustomobject]@{} }
return [pscustomobject]@{ RestrictSendingNTLMTraffic = $script:value }
}
function Get-Item {
param($LiteralPath, $ErrorAction)
$key = [pscustomobject]@{}
$key | Add-Member ScriptMethod GetValueKind {
param($Name)
if ($script:typeReadFails) { throw 'Value kind unavailable' }
return [Microsoft.Win32.RegistryValueKind]$script:type
}
return $key
}
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' }
$script:writes++
if (-not $script:ignoreWrite) { $script:value = $Value }
if (-not $script:ignoreWrite) {
$script:value = $Value
if (-not $script:ignoreTypeWrite) { $script:type = $Type }
}
}
function Get-CimInstance { param($Namespace, $ClassName, $ErrorAction) if ($ClassName -eq 'RSOP_RegistryPolicySetting') { return $script:rsop } }
function Read-Host { param($Prompt) $script:prompts++; if ($script:onPrompt) { & $script:onPrompt }; return $script:response }
@@ -136,4 +152,42 @@ $script:rsop = @(
)
$source = (Get-WelaOutgoingNtlmState).PolicySource
Assert-Equal ($source -like 'Last-applied RSoP GPO: Winning GPO*may be stale*') $true 'Matching RSoP priority and freshness limits are reported'
foreach ($kind in @('String', 'QWord')) {
foreach ($initial in @('0', '1', '2')) {
Reset-Policy $initial $kind
$state = Get-WelaOutgoingNtlmState
Assert-Equal $state.Type $kind 'Outgoing state retains registry kind'
Assert-Equal ($state.Description -like 'Unknown registry type*expected DWord*') $true 'A non-DWORD mode is reported as unknown'
Set-WelaOutgoingNtlmPolicy -Auto
Assert-Equal $script:writes 0 'Default mode preserves malformed outgoing types'
Assert-Equal $script:type $kind 'Default mode preserves the original registry type'
}
foreach ($mode in @('Audit', 'Deny')) {
$desired = if ($mode -eq 'Audit') { 1 } else { 2 }
Reset-Policy ([string]$desired) $kind
Set-WelaOutgoingNtlmPolicy -Mode $mode -Auto
Assert-Equal $script:writes 1 'Explicit mode repairs a matching value with the wrong type'
Assert-Equal $script:type 'DWord' 'Explicit mode writes DWORD'
Assert-Equal $script:value $desired 'Explicit repair preserves the requested policy value'
}
}
Reset-Policy '1' 'String'
$script:ignoreTypeWrite = $true
Assert-Throws { Set-WelaOutgoingNtlmPolicy -Mode Audit -Auto } 'Outgoing read-back rejects the right value with the wrong type'
Assert-Equal $script:writes 1 'Outgoing read-back type failure occurs after an attempted repair'
Reset-Policy 1
$script:typeReadFails = $true
Assert-Equal ((Get-WelaOutgoingNtlmState).Readable) $false 'A registry kind read failure is not a readable outgoing state'
Assert-Throws { Set-WelaOutgoingNtlmPolicy -Mode Audit -Auto } 'Explicit mode fails closed when registry kind cannot be read'
Assert-Equal $script:writes 0 'Unknown outgoing registry kind is never overwritten'
Reset-Policy 0
$script:onPrompt = { $script:value = '1'; $script:type = 'String' }
Set-WelaOutgoingNtlmPolicy
Assert-Equal $script:writes 0 'Default mode preserves malformed types introduced during confirmation'
Assert-Equal $script:type 'String' 'The final pre-write check retains a newly introduced malformed type'
Reset-Policy 0
$script:onPrompt = { $script:value = '1'; $script:type = 'String' }
Set-WelaOutgoingNtlmPolicy -Mode Audit
Assert-Equal $script:writes 1 'Explicit mode repairs a malformed type introduced during confirmation'
Assert-Equal $script:type 'DWord' 'Explicit pre-write decision checks the registry type'
Write-Host "PASS: $script:assertions outgoing NTLM assertions (mocked; no host changes)."
+1
View File
@@ -15,6 +15,7 @@
**バグ修正:**
- `audit-settings`でホストの役割に適用されない監査ポリシーを`Not applicable`と表示し、カテゴリの有効・無効の集計から除外するようにした。NTLMポリシーの値は、DWORD型で保存されている場合にのみ有効な設定値として解釈・検証する。 (#392) (@Shirofune-Security)
- 設定時に外部コマンドの終了コードと変更後の設定値を確認し、処理の終了前にも再確認するようにした。書き込み失敗、設定の未反映、CAサービスの再起動失敗、最終確認時の設定の不一致を明示的に報告し、一律に成功とせず、0以外の終了コードを返すようにした。 (#392) (@Shirofune-Security)
- `configure`で全てのホストに`AuditNTLMInDomain=2`を設定していた問題を修正し、ドメインコントローラと確認できたホストにのみ`7` (Enable all)を設定するようにした。その他のホストや役割を判定できないホストでは、この設定を変更しない。ドメインNTLM監査設定を明示的に表示し、設定後の値の確認とレジストリエラーの報告にも対応した。 (#389) (@Shirofune-Security)
- ルールのフィルタ条件が全て適用されず最後の条件のみが適用されていたため、ルール数が正確ではなかった。 (#358) (@fukusuket)
+1
View File
@@ -17,6 +17,7 @@
**Bug Fixes:**
- `audit-settings` now reports role-inapplicable audit policies as `Not applicable` and excludes them from category enablement totals. NTLM policy values are interpreted and verified only when stored as DWORDs. (#392) (@Shirofune-Security)
- Configuration now checks native command exit codes, verifies settings after applying changes, and checks them again before finishing. Failed writes, ineffective changes, CA restart failures and settings that no longer match at the final check produce explicit results and a nonzero exit code instead of unconditional success. (#392) (@Shirofune-Security)
- Fixed domain NTLM auditing: `configure` now sets `AuditNTLMInDomain=7` (Enable all) only on confirmed domain controllers, instead of writing `2` on every host. This setting is left unchanged on other hosts and hosts whose role cannot be determined. Audit output reports the domain NTLM setting, and configuration verifies registry writes and reports failures. (#389) (@Shirofune-Security)
- Rule filtering applied only the last criterion instead of all of them, so rule counts were inaccurate. (#358) (@fukusuket)