mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
Fix audit applicability, NTLM value types, and native exit verification
This commit is contained in:
1 parent
71215ab498
commit
7d2117ebba
14 files changed
+426
-40
No files matched your search
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)."
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)."
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)."
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user