Merge pull request #392 from Shirofune-Security/fix/365-configuration-results

Verify configuration outcomes with dry-run and recovery journaling
This commit is contained in:
田中ザック Isaac Mathis authored and GitHub committed 2026-09-19 14:06:12 +09:00
commit 27957e859f
32 files changed
+8033 -403

No files matched your search

+37
View File
@@ -0,0 +1,37 @@
name: Audit profile regression tests
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
profiles:
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Test shared profiles in Windows PowerShell 5.1
shell: powershell
run: ./tests/audit-profiles.Tests.ps1
- 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
@@ -0,0 +1,49 @@
name: Configuration result regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
configuration-results:
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Actual script-scope helpers with disposable HKCU key in Windows PowerShell 5.1
shell: powershell
run: ./tests/Test-ConfigurationScriptScopeWindows.ps1
- name: Actual script-scope helpers with disposable HKCU key in PowerShell 7
shell: pwsh
run: ./tests/Test-ConfigurationScriptScopeWindows.ps1
- name: Mocked regressions in Windows PowerShell 5.1
shell: powershell
run: ./tests/Test-ConfigurationResults.ps1
- name: Read-only Windows smoke in Windows PowerShell 5.1
shell: powershell
run: ./tests/Test-ConfigurationReadOnlyWindows.ps1
- name: Mocked regressions in PowerShell 7
shell: pwsh
run: ./tests/Test-ConfigurationResults.ps1
- name: Read-only Windows smoke in PowerShell 7
shell: pwsh
run: ./tests/Test-ConfigurationReadOnlyWindows.ps1
- name: NTLM integration in Windows PowerShell 5.1
shell: powershell
run: ./tests/IntegrationNtlmConfiguration.Tests.ps1
- name: NTLM integration in PowerShell 7
shell: pwsh
run: ./tests/IntegrationNtlmConfiguration.Tests.ps1
- name: Profile integration in Windows PowerShell 5.1
shell: powershell
run: ./tests/IntegrationProfileConfiguration.Tests.ps1
- name: Profile integration in PowerShell 7
shell: pwsh
run: ./tests/IntegrationProfileConfiguration.Tests.ps1
- name: Dry-run and NTLM race safety in Windows PowerShell 5.1
shell: powershell
run: ./tests/IntegrationSafety.Tests.ps1
- name: Dry-run and NTLM race safety in PowerShell 7
shell: pwsh
run: ./tests/IntegrationSafety.Tests.ps1
+2
View File
@@ -38,6 +38,8 @@ jobs:
mkdir -p release-binaries
Copy-Item -Path WELA.ps1 -Destination release-binaries/
Copy-Item -Recurse -Path ./config -Destination release-binaries/
Copy-Item -Recurse -Path ./scripts -Destination release-binaries/
Copy-Item -Recurse -Path ./modules -Destination release-binaries/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
@@ -0,0 +1,19 @@
name: Native audit control regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Verify native policy masks and dependencies (Windows PowerShell 5.1)
shell: powershell
run: ./tests/NativeAuditControls.Tests.ps1
- name: Verify native policy masks and dependencies (PowerShell 7)
shell: pwsh
run: ./tests/NativeAuditControls.Tests.ps1
+19
View File
@@ -0,0 +1,19 @@
name: Outgoing NTLM regressions
on:
push:
branches: ['**']
pull_request:
workflow_dispatch:
permissions:
contents: read
jobs:
test:
runs-on: windows-latest
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Test outgoing NTLM policy in Windows PowerShell 5.1
shell: powershell
run: ./tests/OutgoingNtlm.Tests.ps1
- name: Test outgoing NTLM policy in PowerShell 7
shell: pwsh
run: ./tests/OutgoingNtlm.Tests.ps1
+4
View File
@@ -4,12 +4,16 @@
**改善:**
- `configure`と`configure -Profile`に`-DryRun`と`-ResultsPath`を追加し、Windows設定を変更せずに変更内容を確認し、設定項目ごとの結果をJSONで出力できるようにした。`-DryRun`に対応していないコマンドは、実行前にエラーで停止する。 (#392) (@Shirofune-Security)
- `-BackupPath`と、各設定項目の変更前の状態を記録する復旧用ジャーナルを追加し、手動での復旧手順を文書化した。 (#392) (@Shirofune-Security)
- ベースライン定義を`WELA.ps1`から`config/baselines.json`に外部化し、ベースラインの追加・変更をJSONの編集のみで行えるようにした。 (#358) (@fukusuket)
- `Microsoft-Windows-DFSN-Server/Admin`チャネルを`audit-settings`と`audit-filesize`の確認対象に追加した。 (#358) (@fukusuket)
- MITRE ATT&CK Navigatorのヒートマップを ATT&CK v19 に対応させ、ATT&CK側でrevokedとなった技術IDを置換先に書き換えるようにした(例: v19で`T1685`に統合された`T1562`と`T1562.001`)。Navigatorはrevokedのエントリを黙って破棄するため、従来はその分のカバレッジがヒートマップから欠落していた。 (@fukusuket)
**バグ修正:**
- `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)
- 依存するログが無効になっているルールも使用可能として報告されていた。 (#358) (@fukusuket)
+4
View File
@@ -4,6 +4,8 @@
**Improvements:**
- Added `-DryRun` and `-ResultsPath` to `configure` and `configure -Profile` to preview changes without modifying Windows settings and export per-control results as JSON. Commands that do not support `-DryRun` reject it before running. (#392) (@Shirofune-Security)
- Added `-BackupPath` and a recovery journal that records each control's previous state before making changes, with a documented manual recovery procedure. (#392) (@Shirofune-Security)
- Added a `configure-sacl` command that sets targeted audit SACLs on the autostart/persistence registry keys and sensitive files the detection rules watch, so File System (4663), Registry (4657) and Handle Manipulation (4656) auditing produce useful events without enabling global object auditing. It covers machine-wide objects plus per-user HKCU keys and profile AppData across all user profiles and the Default profile (so future users inherit the SACL). Targets live in `config/audit_sacl_targets.json`. (#361) (@YamatoSecurity)
- `configure` now also enables Detailed Tracking > Process Termination (4689), Object Access > Detailed File Share (5145), and (on domain controllers) LDAP query logging (Directory Service 1644 via NTDS `15 Field Engineering`), so a full detection baseline is applied without any manual `auditpol`/registry steps. (#361) (@YamatoSecurity)
- Baseline definitions were moved out of `WELA.ps1` into a `config/baselines.json` config file, so adding or changing a baseline is now a JSON-only edit. (#358) (@fukusuket)
@@ -12,6 +14,8 @@
**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)
- Rules were reported as usable even when the logs they depend on were disabled. (#358) (@fukusuket)
+2
View File
@@ -42,6 +42,8 @@ Windows event logs are a vital source of information for Digital Forensics and I
(DFIR) — WELA checks your audit policy and log file sizes against best-practice guidelines and
real-world Sigma-rule detectability, and can apply the recommended settings for you.
Advanced audit policy can also use [versioned WELA, Microsoft, CIS and ASD profiles](docs/audit-profiles.md) for shared audit, plan and configure behavior. Profiles cover advanced audit policy only.
## 📖 Documentation
All documentation now lives on a dedicated, searchable, multi-language site:
+371 -400
View File
@@ -3,7 +3,17 @@
[string]$OutType = "std",
[switch]$Debug,
[string]$Baseline,
[string]$Profile,
[ValidateSet("Client", "MemberServer", "DomainController", "ADCS")][string]$Role,
[int]$Build,
[string]$PlanPath,
[switch]$IncludeOptional,
[switch]$Auto,
[ValidateSet("PreserveOrAudit", "Audit", "Deny")]
[string]$OutgoingNtlmMode = "PreserveOrAudit",
[switch]$DryRun,
[string]$BackupPath,
[string]$ResultsPath,
[switch]$Help
)
@@ -17,6 +27,8 @@ $SecurityRulesPath = Join-Path $ScriptRoot "config/security_rules.json"
$EidMappingPath = Join-Path $ScriptRoot "config/eid_subcategory_mapping.csv"
$AuditpolTxtPath = Join-Path $ScriptRoot "auditpol.txt"
$SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/Configuration.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
$PowerShellPolicyRoots = @(
@@ -29,6 +41,7 @@ class WELA {
[string] $Category
[string] $SubCategory
[string] $CurrentSetting = ""
[string] $AuditPolicyGuid = ""
[array] $Rules
[hashtable] $RulesCount
[string] $DefaultSetting = ""
@@ -273,6 +286,60 @@ function GetBaselineNames {
return @((GetBaselineConfig).baselines.PSObject.Properties.Name)
}
function Get-WelaSelectedContext {
if (($script:Role -and -not $script:Build) -or ($script:Build -and -not $script:Role)) {
throw "Specify both -Role and -Build, or neither to detect this Windows host."
}
if ($script:Role -and $script:Build) {
return [pscustomobject]@{ Role = $script:Role; Build = $script:Build }
}
Get-WelaHostContext
}
function Show-WelaAuditProfilePrerequisites {
param($Plan)
foreach ($policy in $Plan.policies) {
if ($policy.prerequisites -and ($policy.mode -in @('exact', 'minimum') -or ($policy.mode -eq 'optional' -and $Plan.includeOptional))) {
Write-Host "Prerequisite - $($policy.id): $($policy.prerequisites)" -ForegroundColor DarkYellow
}
}
}
function Invoke-WelaProfileCommand {
param([string]$Command)
if ($script:Baseline) { throw "Use -Profile or -Baseline, not both. Versioned profiles cover advanced audit policy only." }
if (-not $script:Profile) { throw "Specify -Profile. Use './WELA.ps1 profiles' to list versioned profiles." }
$context = Get-WelaSelectedContext
$current = @{}
if (TestWindows) {
$actual = Get-WelaHostContext
if ($actual.Role -eq $context.Role -and $actual.Build -eq $context.Build) { $current = Get-WelaEffectiveAuditPolicy }
elseif ($Command -ne 'plan') { throw "Requested role/build does not match this Windows host." }
else { Write-Host "Planning for another role/build: effective state remains Unknown." }
}
elseif ($Command -ne 'plan') { throw "Audit and configure require Windows. Offline planning requires explicit -Role and -Build." }
$plan = Get-WelaAuditProfilePlan -Profile $script:Profile -Role $context.Role -Build $context.Build -Current $current -IncludeOptional:$script:IncludeOptional
Write-Host "Profile: $($plan.profile); role: $($plan.role); build: $($plan.build)"
Write-Host "Scope: advanced audit policy only. Channels, command-line capture, PowerShell, NTLM, SACLs, CA AuditFilter and forwarding are separate."
Show-WelaAuditProfilePrerequisites -Plan $plan
$result = $plan
if ($Command -eq 'configure') {
if (-not (TestAdministrator)) { throw "Configuring advanced audit policy requires Administrator privileges." }
Assert-WelaAuditProfileTarget -Plan $plan -Context $actual -Current $current
$configurationContext = New-WelaConfigurationContext -Auto:$script:Auto -DryRun:$script:DryRun -BackupPath $script:BackupPath
Set-WelaProfileAuditControls -Context $configurationContext -Plan $plan
$result = Complete-WelaConfiguration -Context $configurationContext -ResultsPath $script:ResultsPath -Plan $plan -Scope advanced-audit-policy-only
$result.Results | Format-Table Id, Before, Desired, After, Status -AutoSize
} else {
$plan.policies | Format-Table id, mode, currentMask, requiredMask, action -AutoSize
}
if ($script:PlanPath) {
$result | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:PlanPath -Encoding UTF8 -ErrorAction Stop
Write-Host "Machine-readable result: $($script:PlanPath)"
}
if ($Command -eq 'configure' -and $result.ExitCode -ne 0) { throw "One or more advanced audit policies failed. See the effective-state results." }
}
function BuildAuditResult {
param (
[object[]] $all_rules,
@@ -293,8 +360,16 @@ function BuildAuditResult {
$auditpol = GetAuditpol
$auditResult = @()
$sharedPlan = $null
if ($baselineName -eq 'YamatoSecurity') {
$context = Get-WelaSelectedContext
$sharedPlan = Get-WelaAuditProfilePlan -Profile 'wela-2.2.0' -Role $context.Role -Build $context.Build -IncludeOptional:$script:IncludeOptional
Write-Host "Advanced audit recommendations: $($sharedPlan.profile), role $($sharedPlan.role), build $($sharedPlan.build). Other controls use the existing baseline metadata."
}
foreach ($item in $config.catalog) {
# The versioned profile owns all advanced-audit recommendations and canonical GUIDs.
if ($sharedPlan -and $item.currentSetting.type -eq 'auditpol') { continue }
$setting = $settings.($item.id)
if (-not $setting) {
throw "Baseline '$baselineName' has no entry for catalog id '$($item.id)'."
@@ -379,6 +454,30 @@ function BuildAuditResult {
)
}
if ($sharedPlan) {
foreach ($policy in $sharedPlan.policies) {
$rules = ApplyRules -rules $all_rules -guid $policy.guid
$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 }
}
if ($policy.mode -in @('exact', 'minimum') -and $policy.requiredMask -ne 0) {
$rules | ForEach-Object { $_.ideal = $true }
}
$legacyItem = $config.catalog | Where-Object { $_.subCategory -eq $policy.id -and $_.currentSetting.type -eq 'auditpol' } | Select-Object -First 1
$legacy = if ($legacyItem) { $settings.($legacyItem.id) } else { $null }
$defaultSetting = if ($legacy) { $legacy.defaultSetting } else { '' }
$volume = if ($legacy) { $legacy.volume } else { '' }
$note = (@($policy.prerequisites, $policy.note) | Where-Object { $_ }) -join ' '
$entry = [WELA]::New("Security Advanced ($($policy.category))", $policy.id, $current, [array]$rules,
$defaultSetting, $policy.recommendation, $volume, $note)
$entry.AuditPolicyGuid = $policy.guid
$auditResult += $entry
}
}
# どのカテゴリにも該当しなかったルールを取りこぼさない。
# 集計対象から黙って消えると、利用率の分母がルール総数と合わなくなる。
$covered = [System.Collections.Generic.HashSet[string]]::new()
@@ -437,13 +536,23 @@ function AuditLogSetting {
$_ | Add-Member -MemberType NoteProperty -Name "ideal" -Value $false
}
$auditResult = BuildAuditResult -all_rules $all_rules -Baseline $Baseline -enabledguid $enabledguid
$outgoingNtlm = Get-WelaOutgoingNtlmState
$auditResult += [WELA]::new(
"NTLM Authentication", "Outgoing NTLM policy", $outgoingNtlm.Description, @(),
"Not configured (Allow all)", "Audit all (1); preserve intentional Deny all (2)", "",
"RestrictSendingNTLMTraffic. Policy source: $($outgoingNtlm.PolicySource)"
)
# ベースラインが扱っていないサブカテゴリでも、そのサブカテゴリが有効ならルールは動く。
# ルール自身が持つ 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
}
@@ -481,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"
@@ -511,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)
}
@@ -997,6 +1111,7 @@ function Get-WelaDomainNtlmState {
Applicable = $false
Readable = $false
Value = $null
Type = $null
Description = 'Unknown (computer role could not be determined)'
}
try {
@@ -1019,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)" }
}
}
}
}
@@ -1035,7 +1155,11 @@ function Get-WelaDomainNtlmState {
function Set-WelaDomainNtlmAudit {
[CmdletBinding(SupportsShouldProcess = $true)]
param ([switch]$Auto)
param ([switch]$Auto, $Context)
if ($Context) {
Set-WelaNtlmConfigurationControl -Context $Context -Scope Domain -WhatIf:$WhatIfPreference
return
}
$state = Get-WelaDomainNtlmState
Write-Host "Domain NTLM auditing: $($state.Description)"
if (-not $state.Applicable) {
@@ -1045,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
}
@@ -1064,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
@@ -1083,10 +1207,23 @@ function Set-RegistryConfig {
[array]$RegPaths,
[Parameter(Mandatory = $false)]
[switch]$Auto
[switch]$Auto,
$Context
)
foreach ($reg in $RegPaths) {
if ($Context) {
if ($PSCmdlet.ShouldProcess("$($reg.Path)\$($reg.Name)", "Set to $($reg.Value)")) {
Set-WelaRegistryControl -Context $Context -Path $reg.Path -Name $reg.Name -Value $reg.Value
} else {
$Context.Results.Add([pscustomobject]@{
Id = "Registry/$($reg.Path)/$($reg.Name)"; Kind = 'Registry'
Target = @{ Path = $reg.Path; Name = $reg.Name }; Desired = $reg.Value
Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'ShouldProcess declined the change.'
})
}
continue
}
try {
$currentValue = "Not Set"
$pathExists = Test-Path $reg.Path
@@ -1127,70 +1264,173 @@ function Set-RegistryConfig {
}
function Get-WelaOutgoingNtlmPolicySource {
# RSoP is a last-applied policy snapshot, not proof of the current registry writer.
$key = 'SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'
$name = 'RestrictSendingNTLMTraffic'
$matches = @()
foreach ($class in @('RSOP_RegistryPolicySetting', 'RSOP_SecuritySettingNumeric')) {
try {
$matches += @(Get-CimInstance -Namespace 'root\RSOP\Computer' -ClassName $class -ErrorAction Stop |
Where-Object {
$normalizedKey = $_.keyName -replace '^(MACHINE|HKEY_LOCAL_MACHINE|HKLM)\\', ''
($normalizedKey -eq $key -and $_.valueName -eq $name) -or
$normalizedKey -eq "$key\$name"
})
} catch {
# RSoP may be unavailable, including on standalone computers. Never infer "local".
}
}
$policy = $matches | Sort-Object precedence | Select-Object -First 1
if ($policy -and $policy.GPOID) {
return "Last-applied RSoP GPO: $($policy.GPOID) (may be stale; current registry writer unknown)"
}
return 'Unknown (no matching RSoP source available; local, GPO or MDM provenance is not established)'
}
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 {
if (Test-Path -LiteralPath $path -ErrorAction Stop) {
# Reading the key distinguishes an absent value from a failed read.
$properties = Get-ItemProperty -LiteralPath $path -ErrorAction Stop
$property = $properties.PSObject.Properties[$name]
if ($null -ne $property) {
$value = $property.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)" }
}
}
}
}
} catch {
$readable = $false
$description = "Unknown (registry read failed: $($_.Exception.Message))"
}
[pscustomobject]@{
Value = $value
Type = $type
Readable = $readable
Description = $description
PolicySource = Get-WelaOutgoingNtlmPolicySource
}
}
function Set-WelaOutgoingNtlmPolicy {
[CmdletBinding(SupportsShouldProcess = $true)]
param (
[ValidateSet('PreserveOrAudit', 'Audit', 'Deny')]
[string]$Mode = 'PreserveOrAudit',
[switch]$Auto,
$Context
)
if ($Context) {
Set-WelaNtlmConfigurationControl -Context $Context -Scope Outgoing -Mode $Mode -WhatIf:$WhatIfPreference
return
}
$path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'
$name = 'RestrictSendingNTLMTraffic'
$state = Get-WelaOutgoingNtlmState
Write-Host "Outgoing NTLM: $($state.Description)"
Write-Host "Policy source: $($state.PolicySource)"
if (-not $state.Readable) {
throw 'Outgoing NTLM was not changed because its current state could not be read.'
}
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.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.Type -eq 'DWord' -and $state.Value -eq $desired) {
Write-Host "[SKIPPED] Outgoing NTLM is already $description." -ForegroundColor Yellow
return
}
if ($desired -eq 2) {
Write-Warning 'Explicit Deny mode can break NTLM authentication. This is enforcement, not audit-only configuration.'
}
if (-not $PSCmdlet.ShouldProcess("$path\$name", $description)) { return }
if (-not $Auto) {
$response = Read-Host "Change outgoing NTLM from '$($state.Description)' to '$description'? (Y/n)"
if ($response -ne '' -and $response -ne 'Y') {
Write-Host '[SKIPPED] Outgoing NTLM.' -ForegroundColor Yellow
return
}
}
try {
# A prompt or ShouldProcess confirmation may outlive a Group Policy refresh.
# Recheck immediately before mutation so default audit setup cannot undo new enforcement.
$freshState = Get-WelaOutgoingNtlmState
if (-not $freshState.Readable) {
throw 'Outgoing NTLM was not changed because its current state became unreadable.'
}
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.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.Type -eq 'DWord' -and $freshState.Value -eq $desired) {
Write-Host "[SKIPPED] Outgoing NTLM is now already $description." -ForegroundColor Yellow
return
}
if (-not (Test-Path -LiteralPath $path -ErrorAction Stop)) {
New-Item -Path $path -Force -ErrorAction Stop | Out-Null
}
Set-ItemProperty -LiteralPath $path -Name $name -Value $desired -Type DWord -ErrorAction Stop
$after = Get-WelaOutgoingNtlmState
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
Write-Host "Policy source: $($after.PolicySource)"
Write-Host 'Registry state was verified; Group Policy or MDM may reapply a different value.'
} catch {
throw "Outgoing NTLM configuration failed: $($_.Exception.Message)"
}
}
function ConfigureAuditSettings {
param (
[switch] $Auto,
[switch] $Debug
[switch]$Auto, [switch]$Debug, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath,
[ValidateSet("PreserveOrAudit", "Audit", "Deny")]
[string]$OutgoingNtlmMode = "PreserveOrAudit"
)
if (-not (TestWindows)) {
Write-Host "[ERROR] 'configure' changes Windows settings and can only run on Windows." -ForegroundColor Red
return
if (-not (TestWindows)) { throw "'configure' can only run on Windows." }
if (-not (TestAdministrator)) { throw 'This script requires Administrator privileges.' }
# Never use the debug cache to decide whether mutating controls are compliant.
if ($Debug) { Write-Host 'configure always reads live state; the auditpol debug cache is not used.' -ForegroundColor Yellow }
# Reject unsupported roles/builds or unknown required policies before any writes.
$hostContext = Get-WelaHostContext
$effectivePolicy = Get-WelaEffectiveAuditPolicy
$profilePlan = Get-WelaAuditProfilePlan -Profile 'wela-2.2.0' -Role $hostContext.Role -Build $hostContext.Build -Current $effectivePolicy -IncludeOptional:$script:IncludeOptional
Assert-WelaAuditProfileTarget -Plan $profilePlan -Context $hostContext -Current $effectivePolicy
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
if (-not $DryRun) { Write-Host "Recovery journal: $($context.BackupPath)" }
foreach ($log in @('Security', 'Microsoft-Windows-PowerShell/Operational', 'Windows PowerShell')) {
Set-WelaEventLogControl -Context $context -Log $log -Property MaximumSizeInBytes -Desired 1073741824
}
# 管理者権限の確認
if (-not (TestAdministrator)) {
Write-Error "This script requires Administrator privileges"
exit 1
}
if (-not (CollectAuditpol -UseCached:$Debug)) {
return
}
# ログサイズ定数
$oneGB = 1073741824
$oneTwentyEightMB = 134217728
# セキュリティおよびPowerShellログを1GBに設定
Write-Host "Configuring Event Logs..."
Write-Host ""
$largeLogs = @(
"Security",
"Microsoft-Windows-PowerShell/Operational",
"Windows PowerShell"
)
foreach ($log in $largeLogs) {
try {
$logInfo = Get-WinEvent -ListLog $log -ErrorAction Stop
$currentSize = [math]::Floor($logInfo.MaximumSizeInBytes / 1MB)
$newSize = 1024
Write-Host "Log: $log"
if ($currentSize -ge $newSize) {
Write-Host "[SKIPPED] $log : Current size ($currentSize MB) is already greater than or equal to $newSize MB." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentSize MB. Do you want to change it to 1024 MB? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
wevtutil sl $log /ms:$oneGB 2>&1 | Out-Null
Write-Host "[OK] $log : 1024 MB" -ForegroundColor Green
} else {
Write-Host "[SKIPPED] $log" -ForegroundColor Yellow
}
}
catch {
Write-Host "[ERROR] $log : $_" -ForegroundColor Red
}
Write-Host ""
}
# その他の重要なログを128MBに設定
$mediumLogs = @(
"System",
"Application",
@@ -1217,343 +1457,42 @@ function ConfigureAuditSettings {
)
foreach ($log in $mediumLogs) {
try {
$logInfo = Get-WinEvent -ListLog $log -ErrorAction Stop
$currentSize = [math]::Floor($logInfo.MaximumSizeInBytes / 1MB)
$newSize = 128
Write-Host "Log: $log"
if ($currentSize -ge $newSize) {
Write-Host "[SKIPPED] $log : Current size ($currentSize MB) is already greater than or equal to $newSize MB." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentSize MB. Do you want to change it to 128 MB? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
wevtutil sl $log /ms:$oneTwentyEightMB 2>&1 | Out-Null
Write-Host "[OK] $log : 128 MB" -ForegroundColor Green
} else {
Write-Host "[SKIPPED] $log" -ForegroundColor Yellow
}
}
catch {
Write-Host "[ERROR] $log : $_" -ForegroundColor Red
}
Write-Host ""
Set-WelaEventLogControl -Context $context -Log $log -Property MaximumSizeInBytes -Desired 134217728
}
foreach ($log in @('Microsoft-Windows-TaskScheduler/Operational', 'Microsoft-Windows-DriverFrameworks-UserMode/Operational', 'Microsoft-Windows-Crypto-DPAPI/Debug')) {
Set-WelaEventLogControl -Context $context -Log $log -Property IsEnabled -Desired $true
}
# 特定のログの有効化
Write-Host "Enabling Event Logs..."
Write-Host ""
foreach ($log in @("Microsoft-Windows-TaskScheduler/Operational", "Microsoft-Windows-DriverFrameworks-UserMode/Operational", "Microsoft-Windows-Crypto-DPAPI/Debug")) {
try {
$logInfo = Get-WinEvent -ListLog $log -ErrorAction Stop
$currentState = if ($logInfo.IsEnabled) { "Enabled" } else { "Disabled" }
$newState = "Enabled"
Write-Host "Log: $log"
if ($currentState -eq $newState) {
Write-Host "[SKIPPED] $log : Already Enabled." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentState. Do you want to change it to Enabled? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
wevtutil sl $log /e:true 2>&1 | Out-Null
Write-Host "[OK] Enabled: $log" -ForegroundColor Green
} else {
Write-Host "[SKIPPED] $log" -ForegroundColor Yellow
}
}
catch {
Write-Host "[ERROR] Failed to enable $log : $_" -ForegroundColor Red
}
Write-Host ""
}
# PowerShell ロギングの設定
Write-Host "Configuring PowerShell Logging..."
Write-Host ""
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。
# 32bit の PowerShell 用に Wow6432Node 側も併せて設定する。
$regPaths = @()
foreach ($root in $script:PowerShellPolicyRoots) {
$regPaths += @{Path = "$root\ModuleLogging"; Name = "EnableModuleLogging"; Value = 1}
$regPaths += @{Path = "$root\ScriptBlockLogging"; Name = "EnableScriptBlockLogging"; Value = 1}
$regPaths += @{Path = "$root\ModuleLogging"; Name = 'EnableModuleLogging'; Value = 1}
$regPaths += @{Path = "$root\ScriptBlockLogging"; Name = 'EnableScriptBlockLogging'; Value = 1}
}
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto
# モジュール名レジストリの設定
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto -Context $context
foreach ($root in $script:PowerShellPolicyRoots) {
try {
$moduleLoggingPath = "$root\ModuleLogging\ModuleNames"
$currentValue = "Not Set"
$pathExists = Test-Path $moduleLoggingPath
if ($pathExists) {
$prop = Get-ItemProperty -Path $moduleLoggingPath -Name "*" -ErrorAction SilentlyContinue
if ($prop) {
$currentValue = $prop."*"
}
}
Write-Host "Registry: $moduleLoggingPath"
if ($currentValue -eq "*") {
Write-Host "[SKIPPED] Module logging : Already set to * (all modules)." -ForegroundColor Yellow
Write-Host ""
} else
{
if ($Auto)
{
$response = "Y"
}
else
{
$response = Read-Host "Your current setting is $currentValue. Do you want to change it to * (all modules)? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y")
{
if (-not $pathExists)
{
New-Item -Path $moduleLoggingPath -Force | Out-Null
}
Set-ItemProperty -Path $moduleLoggingPath -Name "*" -Value "*" -Type String
Write-Host "[OK] Module logging enabled for all modules" -ForegroundColor Green
}
else
{
Write-Host "[SKIPPED] Module logging" -ForegroundColor Yellow
}
}
}
catch {
Write-Host "[ERROR] Failed to configure module names: $_" -ForegroundColor Red
}
Write-Host ""
Set-WelaRegistryControl -Context $context -Path "$root\ModuleLogging\ModuleNames" -Name '*' -Value '*' -Type String
}
Set-WelaRegistryControl -Context $context -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit' `
-Name ProcessCreationIncludeCmdLine_Enabled -Value 1
# コマンドライン監査の有効化
Write-Host "Enabling Command Line Auditing..."
Write-Host ""
$regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System\Audit"
$valueName = "ProcessCreationIncludeCmdLine_Enabled"
try {
$currentValue = "Not Set"
if (Test-Path $regPath) {
$prop = Get-ItemProperty -Path $regPath -Name $valueName -ErrorAction SilentlyContinue
$currentValue = $prop.$valueName
}
Write-Host "Registry: $regPath"
if ($currentValue -eq 1) {
Write-Host "[SKIPPED] Command Line Auditing : Already Enabled." -ForegroundColor Yellow
Write-Host ""
} else
{
if ($Auto)
{
$response = "Y"
}
else
{
$response = Read-Host "Your current setting is $currentValue. Do you want to change it to 1 (Enabled)? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y")
{
$regPath = $regPath -replace "HKLM:", "HKLM"
$arguments = "add $regPath /v $valueName /f /t REG_DWORD /d 1"
$process = Start-Process -FilePath "reg.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow -RedirectStandardOutput "NUL"
if ($process.ExitCode -eq 0)
{
Write-Host "[OK] Command line auditing enabled" -ForegroundColor Green
}
else
{
Write-Host "[ERROR] Command line auditing failed (ExitCode: $( $process.ExitCode ))" -ForegroundColor Red
}
}
else
{
Write-Host "[SKIPPED] Command line auditing" -ForegroundColor Yellow
}
}
}
catch {
Write-Host "[ERROR] Failed to check command line auditing: $_" -ForegroundColor Red
}
Write-Host ""
# NTLM認証の監査設定
Write-Host "Configuring NTLM Audit Settings..."
Write-Host ""
# NTLM audit/restriction decisions share the recovery and verification context.
Set-WelaOutgoingNtlmPolicy -Mode $OutgoingNtlmMode -Auto:$Auto -Context $context
$regPaths = @(
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "RestrictSendingNTLMTraffic"; Value = 2},
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0"; Name = "AuditReceivingNTLMTraffic"; Value = 2}
)
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto
Set-WelaDomainNtlmAudit -Auto:$Auto
# LDAP query logging (Directory Service EventID 1644) - domain controllers only.
# "15 Field Engineering" = 5 makes expensive / inefficient LDAP searches log as 1644, which surfaces
# BloodHound / SharpHound-style directory reconnaissance. Only applied where the NTDS role is present.
if (Test-Path "HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters") {
Write-Host "Configuring LDAP query logging (1644) on this domain controller..."
Write-Host ""
Set-RegistryConfig -RegPaths $regPaths -Auto:$Auto -Context $context
Set-WelaDomainNtlmAudit -Auto:$Auto -Context $context
if ($hostContext.Role -eq 'DomainController') {
Set-RegistryConfig -RegPaths @(
@{Path = "HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics"; Name = "15 Field Engineering"; Value = 5}
) -Auto:$Auto
@{Path = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics'; Name = '15 Field Engineering'; Value = 5}
) -Auto:$Auto -Context $context
}
# 監査ポリシーの設定
Write-Host "Configuring Audit Policies..."
Write-Host ""
$auditPolicies = @(
@{Category = "Account Logon"; Name = "Credential Validation"; GUID = "0CCE923F-69AE-11D9-BED3-505054503030"},
@{Category = "Account Logon"; Name = "Kerberos Authentication Service"; GUID = "0CCE9242-69AE-11D9-BED3-505054503030"},
@{Category = "Account Logon"; Name = "Kerberos Service Ticket Operations"; GUID = "0CCE9240-69AE-11D9-BED3-505054503030"},
@{Category = "Account Management"; Name = "Computer Account Management"; GUID = "0CCE9236-69AE-11D9-BED3-505054503030"},
@{Category = "Account Management"; Name = "Distribution Group Management"; GUID = "0CCE9238-69AE-11D9-BED3-505054503030"},
@{Category = "Account Management"; Name = "Other Account Management Events"; GUID = "0CCE923A-69AE-11D9-BED3-505054503030"},
@{Category = "Account Management"; Name = "Security Group Management"; GUID = "0CCE9237-69AE-11D9-BED3-505054503030"},
@{Category = "Account Management"; Name = "User Account Management"; GUID = "0CCE9235-69AE-11D9-BED3-505054503030"},
@{Category = "Detailed Tracking"; Name = "Plug and Play"; GUID = "0cce9248-69ae-11d9-bed3-505054503030"},
@{Category = "Detailed Tracking"; Name = "Process Creation"; GUID = "0CCE922B-69AE-11D9-BED3-505054503030"},
@{Category = "Detailed Tracking"; Name = "Process Termination"; GUID = "0CCE922C-69AE-11D9-BED3-505054503030"},
@{Category = "Detailed Tracking"; Name = "RPC Events"; GUID = "0CCE922E-69AE-11D9-BED3-505054503030"},
@{Category = "DS Access"; Name = "Directory Service Access"; GUID = "0CCE923B-69AE-11D9-BED3-505054503030"},
@{Category = "DS Access"; Name = "Directory Service Changes"; GUID = "0CCE923C-69AE-11D9-BED3-505054503030"},
@{Category = "Logon/Logoff"; Name = "Account Lockout"; GUID = "0CCE9217-69AE-11D9-BED3-505054503030"},
@{Category = "Logon/Logoff"; Name = "Logoff"; GUID = "0CCE9216-69AE-11D9-BED3-505054503030"},
@{Category = "Logon/Logoff"; Name = "Logon"; GUID = "0CCE9215-69AE-11D9-BED3-505054503030"},
@{Category = "Logon/Logoff"; Name = "Other Logon/Logoff Events"; GUID = "0CCE921C-69AE-11D9-BED3-505054503030"},
@{Category = "Logon/Logoff"; Name = "Special Logon"; GUID = "0CCE921B-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "Certification Services"; GUID = "0CCE9221-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "File Share"; GUID = "0CCE9224-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "Detailed File Share"; GUID = "0CCE9244-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "Filtering Platform Connection"; GUID = "0CCE9226-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "Other Object Access Events"; GUID = "0CCE9227-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "Removable Storage"; GUID = "0CCE9245-69AE-11D9-BED3-505054503030"},
@{Category = "Object Access"; Name = "SAM"; GUID = "0CCE9220-69AE-11D9-BED3-505054503030"},
@{Category = "Policy Change"; Name = "Audit Policy Change"; GUID = "0CCE922F-69AE-11D9-BED3-505054503030"},
@{Category = "Policy Change"; Name = "Authentication Policy Change"; GUID = "0CCE9230-69AE-11D9-BED3-505054503030"},
@{Category = "Policy Change"; Name = "Other Policy Change Events"; GUID = "0CCE9234-69AE-11D9-BED3-505054503030"},
@{Category = "Privilege Use"; Name = "Sensitive Privilege Use"; GUID = "0CCE9228-69AE-11D9-BED3-505054503030"},
@{Category = "System"; Name = "Security State Change"; GUID = "0CCE9210-69AE-11D9-BED3-505054503030"},
@{Category = "System"; Name = "Security System Extension"; GUID = "0CCE9211-69AE-11D9-BED3-505054503030"},
@{Category = "System"; Name = "System Integrity"; GUID = "0CCE9212-69AE-11D9-BED3-505054503030"},
@{Category = "System"; Name = "Other System Events"; GUID = "0CCE9214-69AE-11D9-BED3-505054503030"}
)
$currentAuditPol = GetAuditpol
foreach ($policy in $auditPolicies)
{
$newSetting = "Success and Failure"
$currentSetting = if ($currentAuditPol.ContainsKey($policy.GUID))
{
$currentAuditPol[$policy.GUID]
}
else
{
"Unknown"
}
Write-Host "Audit Policy: $( $policy.Category ) - $( $policy.Name )"
if ($currentSetting -eq $newSetting)
{
Write-Host "[SKIPPED] $( $policy.Category ) - $( $policy.Name ) : Already set to $newSetting." -ForegroundColor Yellow
Write-Host ""
continue
}
if ($Auto) {
$response = "Y"
} else {
$response = Read-Host "Your current setting is $currentSetting. Do you want to change it to $newSetting? (Y/n)"
}
if ($response -eq "" -or $response -eq "Y" -or $response -eq "y") {
$arguments = "/set /subcategory:{$($policy.GUID)} /success:enable /failure:enable"
$process = Start-Process -FilePath "auditpol.exe" -ArgumentList $arguments -Wait -PassThru -NoNewWindow -RedirectStandardOutput "NUL"
if ($process.ExitCode -eq 0) {
Write-Host "[OK] $($policy.Category) - $($policy.Name)" -ForegroundColor Green
}
else {
Write-Host "[ERROR] $($policy.Category) - $($policy.Name) (ExitCode: $($process.ExitCode))" -ForegroundColor Red
}
} else {
Write-Host "[SKIPPED] $($policy.Category) - $($policy.Name)" -ForegroundColor Yellow
}
Write-Host ""
}
# AD CS AuditFilter の設定
Write-Host "Configuring AD CS Audit Settings..."
try {
$installed = (Get-WindowsFeature -Name AD-Certificate).InstallState -eq "Installed"
} catch {
$installed = $false
}
if ($installed) {
try {
$csRootKey = "HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\"
$caName = (Get-ItemProperty $csRootKey -ErrorAction Stop).Active
$regPath = "HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\$caName"
$prop = Get-ItemProperty -Path $regPath -Name "AuditFilter" -ErrorAction SilentlyContinue
$currentValue = if ($null -ne $prop) { [int]$prop.AuditFilter } else { "Not Set" }
if ($currentValue -eq 127) {
Write-Host "[OK] AuditFilter is already 127" -ForegroundColor Green
}
else {
$proceed = $false
if ($Auto) {
$proceed = $true
}
else {
$response = Read-Host "Do you want to set AuditFilter to 127 and restart Certificate Services? (Y/n)"
$proceed = ($response -eq "" -or $response -match "^[Yy]$")
}
if ($proceed) {
try {
# AuditFilter の設定
& certutil.exe -setreg "CA\AuditFilter" 127 >$null 2>&1
# 証明書サービスの再起動
Restart-Service -Name "CertSvc" -Force -ErrorAction Stop
# 反映確認
$propAfter = Get-ItemProperty -Path $regPath -Name "AuditFilter" -ErrorAction SilentlyContinue
$newValue = if ($null -ne $propAfter) { [int]$propAfter.AuditFilter } else { $null }
if ($newValue -eq 127) {
Write-Host "[OK] AuditFilter set to 127 and CertSvc restarted" -ForegroundColor Green
}
else {
Write-Host "[ERROR] AuditFilter did not apply as expected (current: $newValue)" -ForegroundColor Red
}
}
catch {
Write-Host "[ERROR] Failed to set AuditFilter or restart CertSvc: $_" -ForegroundColor Red
}
}
else {
Write-Host "[SKIP] No changes applied to AuditFilter"
}
}
}
catch {
Write-Host "[ERROR] Failed to process AD CS audit settings: $_" -ForegroundColor Red
}
}
else {
Write-Host "[INFO] AD Certificate Services is not installed. Skipping." -ForegroundColor Yellow
}
Write-Host ""
Write-Host "Configuration completed successfully" -ForegroundColor Green
# Both audit display and mutation use the versioned role-aware profile.
Show-WelaAuditProfilePrerequisites -Plan $profilePlan
Set-WelaProfileAuditControls -Context $context -Plan $profilePlan
Set-WelaCertificateAuditControl -Context $context
Complete-WelaConfiguration -Context $context -ResultsPath $ResultsPath -Plan $profilePlan
}
$logo = @"
@@ -1812,6 +1751,11 @@ function Get-WelaUserProfiles {
$usage = @"
Usage:
./WELA.ps1 profiles # List versioned advanced audit-policy profiles
./WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json
./WELA.ps1 audit-settings -Profile microsoft-sct-win11-24h2 -PlanPath audit.json
./WELA.ps1 configure -Profile asd-native-2021-10 -PlanPath result.json -Auto
# -Profile changes advanced audit policy ONLY. Optional controls need -IncludeOptional.
./WELA.ps1 audit-settings -Baseline YamatoSecurity # Audit current setting and show in stdout, save to csv
./WELA.ps1 audit-settings -Baseline ASD -OutType gui # Audit current setting and show in gui, save to csv
./WELA.ps1 audit-filesize -Baseline YamatoSecurity # Audit current file size and show in stdout, save to csv
@@ -1831,7 +1775,22 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($DryRun -and $Cmd -ne 'configure') {
throw "-DryRun is supported only by configure (including configure -Profile). No command was run."
}
if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'configure') -and -not $Help) {
Invoke-WelaProfileCommand -Command $Cmd.ToLower()
return
}
switch ($Cmd.ToLower()) {
"profiles" {
(Import-WelaAuditProfiles).profiles | Select-Object id, version, scope, appliesTo | Format-List
}
"plan" { Invoke-WelaProfileCommand -Command 'plan' }
"audit" { Invoke-WelaProfileCommand -Command 'audit' }
"audit-settings" {
if ($Help -or [string]::IsNullOrEmpty($Baseline)){
Write-Host "Audit current Windows Event Log settings and compare with baseline"
@@ -1872,12 +1831,17 @@ switch ($Cmd.ToLower()) {
if ($Help){
Write-Host "Configure Windows Event Log audit settings based on the YamatoSecurity baseline"
Write-Host ""
Write-Host "Usage: ./WELA.ps1 configure [-Auto]"
Write-Host "Usage: ./WELA.ps1 configure [-Profile <id>] [-Auto] [-DryRun] [-BackupPath <new-directory>] [-ResultsPath <json-file>] [-OutgoingNtlmMode <PreserveOrAudit|Audit|Deny>]"
Write-Host ""
Write-Host "Options:"
Write-Host " -Profile Configure advanced audit policy only from a versioned profile; list IDs with profiles"
Write-Host " -Auto Automatically configure without prompts"
Write-Host " -OutgoingNtlmMode PreserveOrAudit (default): audit, preserving existing deny; Audit: explicitly replace deny; Deny: opt into enforcement"
Write-Host " -DryRun Read live state and report proposed changes without writing Windows settings"
Write-Host " -BackupPath New directory for the pre-change recovery journal (unique default beside WELA)"
Write-Host " -ResultsPath Save structured per-control outcomes as JSON"
Write-Host ""
Write-Host "Note: only the YamatoSecurity baseline is currently supported for 'configure'."
Write-Host "Without -Profile, configure applies the YamatoSecurity native logging settings. -Profile applies advanced audit policy only. -DryRun and recovery/results options work with both."
Write-Host ""
return
}
@@ -1886,7 +1850,14 @@ switch ($Cmd.ToLower()) {
Write-Host "Re-run with '-Baseline YamatoSecurity' (or omit -Baseline) if that is what you want."
break
}
ConfigureAuditSettings -Auto:$Auto -Debug:$Debug
try {
$report = ConfigureAuditSettings -Auto:$Auto -Debug:$Debug -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath -OutgoingNtlmMode $OutgoingNtlmMode
$report
if ($report.ExitCode -ne 0) { exit $report.ExitCode }
} catch {
Write-Host "[Failed] Configuration aborted: $_" -ForegroundColor Red
exit 1
}
}
"configure-sacl" {
File diff suppressed because it is too large. Load diff
+74
View File
@@ -0,0 +1,74 @@
# Versioned advanced audit-policy profiles
`audit-settings`, `plan`, and `configure` share `config/audit_profiles.json` for advanced Security audit policy. The ordinary `audit-settings -Baseline YamatoSecurity` and ordinary `configure` also use `wela-2.2.0`, eliminating a separate hard-coded configuration list. All 59 subcategories use canonical GUIDs, including categories missing from the older display catalog.
**Profile scope is advanced audit policy only.** Selecting Microsoft, CIS or ASD does not configure their PowerShell settings, command-line capture, channel buffers, NTLM policy, firewall logs, SACLs, CA AuditFilter, forwarding or retention. This is not a claim of full baseline compliance or detection coverage. Sysmon and external sensors are outside this feature. Ordinary `configure` without `-Profile` continues the existing broader WELA setup, with its advanced audit portion supplied by the shared profile.
## Commands
```powershell
# List exact profile ids and role/build applicability.
.\WELA.ps1 profiles
# Offline planning is available on any platform; unknown effective state stays Unknown.
.\WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json
# On Windows, omit Role/Build to detect this host and read effective auditpol values.
.\WELA.ps1 audit-settings -Profile microsoft-sct-win11-24h2 -PlanPath audit.json
# Apply ONLY advanced audit policy. Interactive unless -Auto is supplied.
.\WELA.ps1 configure -Profile asd-native-2021-10 -Auto -PlanPath result.json
# Select optional File System/Registry policy flags, without creating SACLs.
.\WELA.ps1 configure -Profile asd-native-2021-10 -IncludeOptional -PlanPath result.json
```
Supply both `-Role` and `-Build`, or omit both for Windows host detection. Roles are `Client`, `MemberServer`, `DomainController`, and `ADCS` (CA on a member server). Combined DC/CA deployments are not supported by these role profiles: host detection refuses them before configuration writes, rather than silently omitting CA auditing. A failure to read the CA installation state is also an error, not evidence of a member server without CA. Build means the base build, for example 20348 (Server 2022), 26100 (Windows 11 24H2 / Server 2025), or 26200 (Windows 11 25H2). Live application checks the actual Windows host; a supplied role/build cannot authorize applying a mismatched plan. Versioned SCT profiles reject other base builds. Unsupported profiles/hosts and unreadable policies fail before writes.
The WELA and documentary guide profiles currently cover the reviewed Windows 11/Server 2022/Server 2025 range. Older/future operating systems require a reviewed applicability update. `-Baseline` retains the legacy display interface for non-Yamato guides; use `-Profile` to select the versioned shared definitions. Do not combine `-Baseline` and `-Profile`.
## Included sources
| Profile | Version / meaning |
| --- | --- |
| `wela-2.2.0` | Reviewed WELA development snapshot `8ef938f0966e86adc527395f50f907c43e843d1e`; extends the 34 existing policies with [six native audit controls](../website/docs/commands/native-audit-controls.md), with irrelevant roles skipped and three SACL prerequisites optional |
| `windows-defaults-reviewed-2026-09` | Documentary effective-default model; **reference only**, cannot be applied or used to reset an OS |
| `microsoft-sct-win11-24h2`, `microsoft-sct-win11-25h2` | Official SCT Policy Analyzer settings, exact masks |
| `microsoft-sct-server2022`, `microsoft-sct-server2025-2602` | Official SCT member/DC settings; AD CS uses the member-server baseline |
| `microsoft-stronger-reviewed-2026-09` | Stronger audit recommendation column; minimum enabled flags, conditional IPsec opt-in; ambiguous unspecified success/failure values preserved |
| `microsoft-wef-reviewed-2026-09` | WEF Appendix A minimum audit policy, preserving explicit Not Configured |
| `microsoft-identity-reviewed-2026-09` | Identity collection's DC/CA advanced audit requirements only; other prerequisites remain separate |
| `cis-win11-v4-l1`, `cis-win11-v4-l2` | Historical Windows 11 Enterprise v4.0.0, retaining “includes” minimum semantics |
| `cis-server2022-v4-l1`, `cis-server2022-v4-l2` | Historical Server 2022 v4.0.0, role-aware DC requirements |
| `asd-native-2021-10` | ASD native fallback; optional object auditing and explicit Detailed File Share Not Configured |
CIS v4.0.0 is not the latest CIS edition. Defaults combine documentary evidence that is not a clean-install measurement, and some Server values are shared across roles. The defaults profile is deliberately blocked from application. Source URLs, versions, setting-level evidence, notes and prerequisites are in the JSON and exported plans. The catalog GUID reference is [Microsoft MS-GPAC](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpac/77878370-0712-47cd-997d-b07053429f6d).
## Policy semantics
Mask bits are Success `1`, Failure `2`, both `3`, neither `0`.
| Mode | Behavior |
| --- | --- |
| `exact` | Set the exact mask; may remove an existing success/failure flag |
| `minimum` | Bitwise OR with fresh effective state, preserving additional auditing |
| `unchanged` | Preserve current state; every omitted control becomes an explicit unchanged plan row |
| `not-configured` | Preserve effective policy; do not interpret it as disabled and do not remove a GPO |
| `optional` | Preserve unless `-IncludeOptional` is supplied; then set the explicit mask |
| `not-applicable` | Preserve; skip a subcategory outside the selected role |
For example Detailed File Share is exact S+F in WELA, minimum Failure in the reviewed CIS profiles, and Not Configured in ASD. These are deliberate differences, not a universal “enable everything” preset. Omitted values and unknown effective state are different: unknown state blocks application instead of becoming mask zero.
Effective policy is read through the Windows [AuditQuerySystemPolicy API](https://learn.microsoft.com/en-us/windows/win32/api/ntsecapi/nf-ntsecapi-auditquerysystempolicy); localized `auditpol /get /r` text is not parsed. Apply reads effective state immediately before each control, checks native command errors, then verifies each changed policy. Minimum policies only enable required native flags, never disable additional flags, and accept any effective state containing the required bits. Exact policies require the exact mask. A command that exits successfully but does not change effective policy is reported as failed. Group Policy can reapply after a successful verification: these are local effective-policy changes, not GPO authoring. Exported plan/current state and apply results include the profile version, schema SHA-256, source provenance, before/target/effective masks and failure details. This feature does not validate event generation, SACL correctness, ingestion, or Sigma field compatibility.
## Extending the schema and testing
Add a catalog entry with a unique GUID, category, supported roles and prerequisite text. Add or override profile controls using `mode`, `mask` (only for exact/minimum/optional), optional `note`, `evidence`, and `sourceIds`. A profile supplies `sourceIds`, an explicit role/build range, `omitted: unchanged`, and `scope: advanced-audit-policy-only`. Optional control source ids are added to profile provenance. Do not silently revise a published source version when its semantics change.
```powershell
# Pure tests, including injected native boundaries; no policy changes or elevation.
pwsh -NoProfile -File tests/audit-profiles.Tests.ps1
powershell -NoProfile -File tests/audit-profiles.Tests.ps1
```
The tests cover source/schema validation, role/build gating, exact/minimum/optional/NC behavior, locale-independent native policy reads, unknown-state refusal, fresh-state merging, idempotence, failed commands and verification, and the ordinary Yamato audit display. CI runs these on Windows PowerShell 5.1 and PowerShell 7. Source review and mocked tests are not substitutes for checking effective policy and benign event XML on isolated Windows clients, member servers, DCs and CAs.
+158
View File
@@ -0,0 +1,158 @@
# Verified configuration and recovery
`configure` reads live state, records each proposed write before executing it,
checks native exit codes, and reads the resulting state. It returns an object with
`ExitCode`, `DryRun`, `BackupPath`, `Failed`, `Skipped`, and a `Results` array.
`-ResultsPath` also saves that object as JSON. The command exits with status 1 when
any control fails or changes again before the final verification. A fatal preflight
or result-file error also exits with status 1.
```powershell
# Read live settings; do not change Windows settings, restart services or create a journal.
.\WELA.ps1 configure -DryRun -ResultsPath .\proposed-results.json
# Apply with interactive approval for each change, including the CA restart.
.\WELA.ps1 configure -BackupPath C:\WELA-Recovery\run-001 -ResultsPath .\results.json
# Apply the existing WELA choices without individual prompts.
.\WELA.ps1 configure -Auto -ResultsPath .\results.json
```
Keep the complete WELA directory, including `scripts/Configuration.ps1`. Choose a
recovery path whose parent directory is writable only by the operators who manage
these settings. The backup directory must not already exist. Without `-BackupPath`,
a unique directory is created beside WELA. `-Debug` does not substitute cached
audit policy data during configuration. `-DryRun` may write the explicitly requested
result file, but performs no Windows configuration writes.
| Status | Meaning |
| --- | --- |
| Applied | Write succeeded and immediate read-back matched. |
| AlreadyCompliant | The initial live value already met the requirement; no write. |
| Skipped | Dry run, operator decline, or no configured local CA. |
| Failed | State could not be read, journaling failed, write/restart failed, or verification failed. |
| Overridden | A value verified earlier became noncompliant by the final read. Cause is unknown. |
Unknown and unavailable channels are reported as failed observations rather than
silently claiming that logging is enabled. Partial runs and runs with skipped
controls do not claim universal success. Verification is an observation at that
moment; it does not prove future GPO persistence, event production, collection or
Sigma rule coverage. A zero exit code with skipped controls is not full compliance.
Audit policy reads use GUIDs and numeric flags from the Windows
[AuditQuerySystemPolicy API](https://learn.microsoft.com/en-us/windows/win32/api/ntsecapi/nf-ntsecapi-auditquerysystempolicy).
`auditpol /get /r` contains localized labels and no numeric setting column; it is
not parsed as though it were `auditpol /backup` output. Registry writes use terminating errors and verify
both the value and registry type. Log sizes retain larger existing buffers. A CA
is detected from its configured registry state; certutil must succeed before a
restart is attempted, and the restart must return to Running. A stopped CA is not
started automatically. A restart failure remains failed even if the registry value
was already written.
## Recovery journal and rollback design
Each line of `before.jsonl` records the computer, timestamp, control identity,
requested setting and exact pre-change state. Registry entries include whether the
key/value existed and the previous registry type. Event-log entries capture size or
enabled state; audit policies capture the numeric mask; CA entries also capture
service state. A journal write failure prevents that control's mutation. The
journal is per control, not a full system backup, and can contain records for failed
or declined downstream actions. Save the final result file alongside it.
This change provides a guarded **manual recovery procedure**, not an automatic
rollback command. Automatic bulk rollback could overwrite a later administrator or
GPO change and could interrupt certificate services. Before recovery:
1. Use an elevated shell on the journal's recorded computer. Review the specific
failed or applied control and capture its current live state.
2. Compare current state with the recorded requested/verified after-state. If it
differs, stop and determine whether another writer made an intentional change.
Do not blindly replay a journal or restore an entire audit policy backup.
3. Restore only the intended controls, normally in reverse application order:
- **EventLog:** `wevtutil sl <log> /ms:<previous-bytes>` or `/e:<previous-bool>`.
Review shrinking buffers or disabling a channel before proceeding.
- **AuditPolicy:** `auditpol /set /subcategory:{<guid>} /success:<enable|disable>
/failure:<enable|disable>`. Previous mask bit 1 means success, bit 2 means
failure. Restore that subcategory, not unrelated policy.
- **Registry:** restore the previous value using its recorded registry type.
If the value did not exist, remove only that value. Preserve unrelated values
and never recursively delete a newly created parent key. Binary and multistring
old values must be reconstructed with their original types from the JSON.
- **CertificateService:** restore the active CA's previous AuditFilter value (or
its original absence) and separately approve the necessary service restart.
Do not start a CA that was deliberately stopped. A failed restart can leave
the registry and running service out of sync; an operator must resolve this.
4. Check every native exit code and read the restored state. Keep the recovery
commands and observations with the original journal.
A future automated rollback command should require the same host and control
identity, validate journal schema and allowlisted types, check current state against
recorded after-state, refuse unexpected drift, journal recovery itself, and require
explicit approval for CA restarts. It should never import the whole registry or
force a Group Policy setting. These are design constraints, not implemented claims.
## Testing
`tests/Test-ConfigurationResults.ps1` uses mock Windows APIs and disposable temp
journals. It exercises nonzero native exits and stderr, false-success writes,
read-back, idempotence, final drift, dry runs, journal failure, locale-independent native audit flags, and CA write/restart failure. It does not change Windows settings.
`tests/Test-ConfigurationReadOnlyWindows.ps1` runs real read-only Windows audit-policy API and `auditpol /get` queries
and a child `cmd.exe` diagnostic/exit test. CI runs both scripts in Windows PowerShell
5.1 and PowerShell 7. Mutating behavior still requires isolated Windows/CA lab
validation; mock and read-only tests do not establish end-to-end event production.
## NTLM policy integration
Outgoing and domain NTLM decisions use the same configuration context. `-DryRun`
prevents both writes, and actual changes are journaled with their original registry
types before execution. Applied values participate in the final drift check.
`PreserveOrAudit` preserves an existing outgoing deny (`2`) and unknown numeric
values, recording the reason as `Skipped`; explicit `Audit` and `Deny` remain
available through `-OutgoingNtlmMode`. Non-DC domain auditing is `Skipped`.
Unknown domain role, unreadable policy and failed writes produce `Failed` outcomes
and a nonzero overall result while allowing other controls to be assessed.
`tests/IntegrationNtlmConfiguration.Tests.ps1` exercises this composed behavior
using mocked registry/CIM calls and temporary journals only.
## Versioned profile integration
`configure -Profile <id>` uses the same dry-run, recovery-journal and verification
runner as the broader default `configure` command. Host role/build and all required
effective audit settings are validated before creating a journal or changing any
Windows setting. The Windows-defaults profile remains read-only.
```powershell
.\WELA.ps1 configure -Profile cis-win11-v4-l1 -DryRun -ResultsPath .\cis-plan.json
.\WELA.ps1 configure -Profile microsoft-sct-win11-24h2 -Auto -BackupPath C:\WELA-Recovery\sct-001 -ResultsPath .\sct-results.json
```
Exact recommendations set the named mask; minimum recommendations only enable
required flags and accept a compliant superset. They never disable an unrequested
flag, including one added by another writer between observation and application.
Omitted, Not Configured and non-applicable policies are preserved. Opt-in policies
require `-IncludeOptional`. Both result paths retain version, host role/build,
source identifiers and prerequisites such as SACLs; recording an enabled audit
subcategory does not claim its prerequisite was installed.
The result `Scope` is `native-windows-configuration` for default configure and
`advanced-audit-policy-only` for `configure -Profile`. `ProfileScope` describes the
advanced-policy subset within either result. `-PlanPath` remains available for
profile JSON output; `-ResultsPath` saves the verified configuration report.
This composed change depends on the outgoing/domain NTLM corrections, versioned
audit profiles and six additional native audit controls. It preserves their
selection behavior while adding shared execution and recovery reporting.
`tests/IntegrationProfileConfiguration.Tests.ps1` tests the composed command
paths without touching Windows policy, including exact/minimum behavior, concurrent
flags, unknown-state preflight, reference-only defaults, metadata and dry runs.
`-DryRun` is supported only by `configure`, including its `-Profile` form. Other
commands reject the flag before dispatch, so `configure-sacl -DryRun` and
`update-rules -DryRun` cannot silently perform their normal mutations.
Outgoing `PreserveOrAudit` checks the shared runner's fresh registry snapshot and
checks again after prompting and journaling, immediately before the value write.
Newly observed deny or unknown states are preserved or refused with an explicit
result; changing them requires an explicit `Audit` or `Deny` choice. Windows does
not provide an atomic compare-and-set through this registry provider, so a
concurrent writer after the final check remains outside this guarantee.
+311
View File
@@ -0,0 +1,311 @@
# Requires Windows PowerShell 5.1 or PowerShell 7. No Windows dependency for schema/planning.
Set-StrictMode -Version 2.0
function Get-WelaProperty {
param($Object, [string]$Name, $Default = $null)
if ($null -ne $Object -and $null -ne $Object.PSObject.Properties[$Name]) { return $Object.$Name }
return $Default
}
function Import-WelaAuditProfiles {
[CmdletBinding()]
param([string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json'))
$data = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($data.schemaVersion -ne 1) { throw 'Unsupported audit profile schema version.' }
$roles = @('Client', 'MemberServer', 'DomainController', 'ADCS')
$ids = @{}; $guids = @{}; $profileIds = @{}
foreach ($policy in $data.catalog) {
if (-not $policy.id -or $ids.ContainsKey($policy.id)) { throw "Duplicate or empty policy id: $($policy.id)" }
if ($policy.guid -notmatch '^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$' -or $guids.ContainsKey($policy.guid)) { throw "Invalid or duplicate GUID: $($policy.guid)" }
if (@($policy.roles).Count -eq 0 -or @($policy.roles | Where-Object { $_ -notin $roles }).Count) { throw "Invalid policy roles: $($policy.id)" }
$ids[$policy.id] = $true; $guids[$policy.guid] = $true
}
foreach ($profile in $data.profiles) {
if (-not $profile.id -or $profileIds.ContainsKey($profile.id)) { throw "Duplicate or empty profile id: $($profile.id)" }
$profileIds[$profile.id] = $true
if ($profile.omitted -ne 'unchanged' -or $profile.scope -ne 'advanced-audit-policy-only' -or -not $profile.version) { throw "Invalid profile metadata: $($profile.id)" }
if (@($profile.sourceIds).Count -eq 0) { throw "Missing profile provenance: $($profile.id)" }
foreach ($source in $profile.sourceIds) {
if (-not $data.sources.PSObject.Properties[$source]) { throw "Unknown profile source: $source" }
}
if (@($profile.appliesTo).Count -eq 0) { throw "Missing applicability: $($profile.id)" }
foreach ($range in $profile.appliesTo) {
if (@($range.roles).Count -eq 0 -or @($range.roles | Where-Object { $_ -notin $roles }).Count -or $range.minBuild -lt 0 -or $range.maxBuild -lt $range.minBuild) { throw "Invalid applicability: $($profile.id)" }
}
$sets = @($profile.controls)
foreach ($override in $profile.roleOverrides.PSObject.Properties) {
if ($override.Name -notin $roles) { throw "Unknown role override: $($override.Name)" }
$sets += $override.Value
}
foreach ($set in $sets) {
foreach ($property in $set.PSObject.Properties) {
if (-not $ids.ContainsKey($property.Name)) { throw "Unknown audit policy: $($property.Name)" }
$control = $property.Value
foreach ($sourceId in @(Get-WelaProperty $control 'sourceIds' @())) {
if (-not $data.sources.PSObject.Properties[$sourceId]) { throw "Unknown control source: $sourceId" }
}
if ($control.mode -notin @('exact', 'minimum', 'unchanged', 'not-configured', 'optional', 'not-applicable')) { throw "Invalid mode: $($control.mode)" }
$hasMask = $null -ne $control.PSObject.Properties['mask']
if ($control.mode -in @('exact', 'minimum', 'optional')) {
if (-not $hasMask -or $control.mask -isnot [ValueType] -or $control.mask -is [bool] -or $control.mask -notin @(0, 1, 2, 3) -or [double]$control.mask -ne [int]$control.mask) { throw "Invalid mask: $($property.Name)" }
} elseif ($hasMask) { throw "Non-setting mode cannot have a mask: $($property.Name)" }
}
}
}
return $data
}
function Format-WelaAuditMask {
param($Mask)
if ($null -eq $Mask) { return 'Unknown' }
switch ([int]$Mask) { 0 { 'No Auditing' } 1 { 'Success' } 2 { 'Failure' } 3 { 'Success and Failure' } default { throw "Invalid mask: $Mask" } }
}
function Get-WelaAuditProfilePlan {
[CmdletBinding()]
param(
[Parameter(Mandatory)][string]$Profile,
[Parameter(Mandatory)][ValidateSet('Client', 'MemberServer', 'DomainController', 'ADCS')][string]$Role,
[Parameter(Mandatory)][ValidateRange(1, 999999)][int]$Build,
[hashtable]$Current = @{}, [switch]$IncludeOptional,
[string]$Path = (Join-Path $PSScriptRoot '../config/audit_profiles.json')
)
$data = Import-WelaAuditProfiles -Path $Path
$selected = @($data.profiles | Where-Object { $_.id -eq $Profile })
if ($selected.Count -ne 1) { throw "Unknown audit profile '$Profile'. Use -Cmd profiles to list profiles." }
$selected = $selected[0]
$matches = @($selected.appliesTo | Where-Object { $Role -in $_.roles -and $Build -ge $_.minBuild -and $Build -le $_.maxBuild })
if ($matches.Count -eq 0) { throw "Profile '$Profile' does not support role '$Role', build '$Build'." }
foreach ($value in $Current.Values) {
if ($null -ne $value -and ($value -is [bool] -or $value -notin @(0, 1, 2, 3))) { throw "Invalid effective audit mask: $value" }
}
$controls = @{}
foreach ($property in $selected.controls.PSObject.Properties) { $controls[$property.Name] = $property.Value }
$override = Get-WelaProperty $selected.roleOverrides $Role
if ($override) { foreach ($property in $override.PSObject.Properties) { $controls[$property.Name] = $property.Value } }
$rows = foreach ($policy in $data.catalog) {
$control = $controls[$policy.id]
$mode = if ($control) { $control.mode } else { 'unchanged' }
if ($Role -notin $policy.roles) { $mode = 'not-applicable' }
$mask = Get-WelaProperty $control 'mask'
$currentMask = if ($Current.ContainsKey($policy.guid)) { $Current[$policy.guid] } else { $null }
$desired = $null; $action = 'Preserve'; $compliance = 'Not assessed'
if ($mode -eq 'not-applicable') { $action = 'Not applicable'; $mask = $null }
elseif ($mode -eq 'optional' -and -not $IncludeOptional) { $action = 'Optional (not selected)' }
elseif ($mode -in @('exact', 'minimum', 'optional')) {
if ($null -eq $currentMask) { $action = 'Unknown'; $compliance = 'Unknown' }
else {
$desired = if ($mode -eq 'minimum') { [int]$currentMask -bor [int]$mask } else { [int]$mask }
$action = if ($currentMask -eq $desired) { 'No change' } else { 'Set' }
$compliance = if ($action -eq 'No change') { 'Compliant' } else { 'Drift' }
}
}
[pscustomobject][ordered]@{
id = $policy.id; guid = $policy.guid; category = $policy.category; mode = $mode
requiredMask = $mask; currentMask = $currentMask; targetMask = $desired
recommendation = if ($mode -in @('exact', 'minimum', 'optional')) { "$(Format-WelaAuditMask $mask) [$mode]" } else { $mode }
action = $action; compliance = $compliance; prerequisites = $policy.prerequisites
note = Get-WelaProperty $control 'note' ''; evidence = Get-WelaProperty $control 'evidence' ''
sourceIds = @(@($selected.sourceIds) + @(Get-WelaProperty $control 'sourceIds' @()) | Select-Object -Unique)
}
}
$sourceIds = @($rows | ForEach-Object { $_.sourceIds } | Select-Object -Unique)
$sources = foreach ($id in $sourceIds) { [pscustomobject]@{ id = $id; source = $data.sources.$id } }
[pscustomobject][ordered]@{
schemaVersion = 1; profile = $selected.id; version = $selected.version
scope = $selected.scope; role = $Role; build = $Build; includeOptional = [bool]$IncludeOptional
referenceOnly = [bool](Get-WelaProperty $selected 'referenceOnly' $false)
generatedUtc = [DateTime]::UtcNow.ToString('o'); schemaSha256 = (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash
note = Get-WelaProperty $selected 'note' ''; provenance = @($sources); policies = @($rows)
}
}
function Get-WelaEffectiveAuditPolicy {
[CmdletBinding()]
param()
# auditpol /get /r has localized text and no numeric mask column. Query the native API instead.
if (-not ('Wela.AuditProfiles.NativePolicy' -as [type])) {
Add-Type -TypeDefinition @'
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace Wela.AuditProfiles {
public static class NativePolicy {
[StructLayout(LayoutKind.Sequential)]
private struct PolicyInformation {
public Guid Subcategory;
public UInt32 Information;
public Guid Category;
}
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.U1)]
private static extern bool AuditQuerySystemPolicy(
[In, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] Guid[] subcategories,
UInt32 count, out IntPtr information);
[DllImport("advapi32.dll")]
private static extern void AuditFree(IntPtr buffer);
public static Dictionary<string, int> Read(Guid[] subcategories) {
IntPtr buffer = IntPtr.Zero;
try {
if (!AuditQuerySystemPolicy(subcategories, (UInt32)subcategories.Length, out buffer))
throw new Win32Exception(Marshal.GetLastWin32Error(), "AuditQuerySystemPolicy failed");
if (buffer == IntPtr.Zero) throw new InvalidOperationException("Audit policy API returned a null buffer.");
int size = Marshal.SizeOf(typeof(PolicyInformation));
var result = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
for (int i = 0; i < subcategories.Length; i++) {
var policy = (PolicyInformation)Marshal.PtrToStructure(IntPtr.Add(buffer, i * size), typeof(PolicyInformation));
// POLICY_AUDIT_EVENT_NONE = 4; success/failure are bits 1 and 2.
if (policy.Information > 4U) throw new InvalidOperationException("Unrecognized native audit flags.");
result.Add(policy.Subcategory.ToString().ToUpperInvariant(), (int)(policy.Information & 3U));
}
return result;
} finally { if (buffer != IntPtr.Zero) AuditFree(buffer); }
}
}
}
'@ -ErrorAction Stop
}
$catalog = (Import-WelaAuditProfiles).catalog
[guid[]]$guids = @($catalog | ForEach-Object { [guid]$_.guid })
$native = [Wela.AuditProfiles.NativePolicy]::Read($guids)
$current = @{}
foreach ($policy in $catalog) {
if (-not $native.ContainsKey($policy.guid)) { throw "Audit policy API omitted $($policy.id)." }
$current[$policy.guid] = $native[$policy.guid]
}
return $current
}
function Get-WelaAuditSetArguments {
param(
[ValidatePattern('^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$')][string]$Guid,
[ValidateRange(0, 3)][int]$Mask,
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact'
)
$arguments = @('/set', "/subcategory:{$Guid}")
if ($Mode -eq 'minimum') {
# Only enable required bits; never clear another actor's newly enabled bit.
if ($Mask -band 1) { $arguments += '/success:enable' }
if ($Mask -band 2) { $arguments += '/failure:enable' }
} else {
$arguments += if ($Mask -band 1) { '/success:enable' } else { '/success:disable' }
$arguments += if ($Mask -band 2) { '/failure:enable' } else { '/failure:disable' }
}
return $arguments
}
function Set-WelaEffectiveAuditPolicy {
param(
[ValidatePattern('^[0-9A-Fa-f]{8}(-[0-9A-Fa-f]{4}){3}-[0-9A-Fa-f]{12}$')][string]$Guid,
[ValidateRange(0, 3)][int]$Mask,
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact'
)
if ($Mode -eq 'minimum' -and $Mask -eq 0) { return }
$arguments = @(Get-WelaAuditSetArguments -Guid $Guid -Mask $Mask -Mode $Mode)
$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 {
[CmdletBinding()]
param(
[scriptblock]$ReadOperatingSystem = { Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop },
[scriptblock]$ReadComputerSystem = { Get-CimInstance -ClassName Win32_ComputerSystem -ErrorAction Stop },
[scriptblock]$ReadCertificateAuthority = { Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration' -ErrorAction Stop }
)
$os = & $ReadOperatingSystem
$system = & $ReadComputerSystem
if ([int]$os.ProductType -notin @(1, 2, 3) -or [int]$system.DomainRole -notin @(0, 1, 2, 3, 4, 5) -or [int]$os.BuildNumber -le 0) { throw 'Cannot determine a valid Windows role/build.' }
if (([int]$os.ProductType -eq 1 -and [int]$system.DomainRole -notin @(0, 1)) -or
([int]$os.ProductType -eq 2 -and [int]$system.DomainRole -notin @(4, 5)) -or
([int]$os.ProductType -eq 3 -and [int]$system.DomainRole -notin @(2, 3))) { throw 'Windows ProductType and DomainRole disagree.' }
$hasCA = $false
if ([int]$os.ProductType -ne 1) {
$hasCA = & $ReadCertificateAuthority
if ($hasCA -isnot [bool]) { throw 'Cannot determine whether Certificate Services is installed.' }
if ($hasCA -and [int]$system.DomainRole -in @(4, 5)) { throw 'Combined domain-controller/CA hosts are unsupported by the current role profiles. No configuration should be applied.' }
}
$role = if ([int]$os.ProductType -eq 1) { 'Client' }
elseif ([int]$system.DomainRole -in @(4, 5)) { 'DomainController' }
elseif ($hasCA) { 'ADCS' }
else { 'MemberServer' }
[pscustomobject]@{ Role = $role; Build = [int]$os.BuildNumber }
}
function Assert-WelaAuditProfileTarget {
[CmdletBinding()]
param([Parameter(Mandatory)]$Plan, [Parameter(Mandatory)]$Context, [Parameter(Mandatory)]$Current)
if ($Plan.referenceOnly) { throw 'Windows defaults are a reference, not an apply/restore profile.' }
if ($Context.Role -ne $Plan.role -or $Context.Build -ne $Plan.build) { throw 'Plan role/build does not match the actual Windows host.' }
if ($Current -isnot [hashtable]) { throw 'Effective policy reader did not return a GUID-to-mask map.' }
$selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) })
foreach ($policy in $selected) {
if (-not $Current.ContainsKey($policy.guid) -or $null -eq $Current[$policy.guid] -or $Current[$policy.guid] -notin @(0, 1, 2, 3)) { throw "Cannot apply with unknown current policy: $($policy.id). No policies changed." }
}
}
function Invoke-WelaAuditProfilePlan {
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]$Plan,
[scriptblock]$ReadPolicy = { Get-WelaEffectiveAuditPolicy },
[scriptblock]$WritePolicy,
[scriptblock]$ReadContext = { Get-WelaHostContext }
)
$hostContext = & $ReadContext
$before = & $ReadPolicy
Assert-WelaAuditProfileTarget -Plan $Plan -Context $hostContext -Current $before
$selected = @($Plan.policies | Where-Object { $_.mode -in @('exact', 'minimum') -or ($_.mode -eq 'optional' -and $Plan.includeOptional) })
$results = foreach ($policy in $selected) {
$initial = $null; $effective = $null; $target = $null; $errorText = $null; $status = 'No change'
try {
# Whole-plan preflight is not a current-state cache: re-read immediately before each control.
$fresh = & $ReadPolicy
if ($fresh -isnot [hashtable] -or -not $fresh.ContainsKey($policy.guid) -or $null -eq $fresh[$policy.guid] -or $fresh[$policy.guid] -notin @(0, 1, 2, 3)) { throw 'Current audit policy became unknown before application.' }
$initial = $fresh[$policy.guid]; $effective = $initial
$isMinimum = $policy.mode -eq 'minimum'
$target = if ($isMinimum) { [int]$initial -bor [int]$policy.requiredMask } else { [int]$policy.requiredMask }
if ($initial -ne $target) {
if ($PSCmdlet.ShouldProcess($policy.id, "Set audit policy to $(Format-WelaAuditMask $target)")) {
$writeMode = if ($isMinimum) { 'minimum' } else { 'exact' }
if ($WritePolicy) {
# Existing two-argument test providers retain their merged-mask contract.
# A third mode argument lets providers preserve concurrent additional flags.
& $WritePolicy $policy.guid $target $writeMode | Out-Null
} else {
$writeMask = if ($isMinimum) { $policy.requiredMask } else { $target }
Set-WelaEffectiveAuditPolicy -Guid $policy.guid -Mask $writeMask -Mode $writeMode
}
$verified = & $ReadPolicy
$effective = if ($verified -is [hashtable] -and $verified.ContainsKey($policy.guid)) { $verified[$policy.guid] } else { $null }
if ($null -eq $effective -or $effective -notin @(0, 1, 2, 3)) { throw 'Effective policy is unknown after application.' }
$matches = if ($isMinimum) { ([int]$effective -band [int]$policy.requiredMask) -eq [int]$policy.requiredMask } else { $effective -eq $target }
if (-not $matches) { throw 'Effective policy does not meet the requested audit requirement (GPO or command failure).' }
$status = 'Applied'
} else { $status = 'Skipped' }
}
} catch { $status = 'Failed'; $errorText = $_.Exception.Message; $effective = $null }
[pscustomobject]@{
id = $policy.id; guid = $policy.guid; mode = $policy.mode
beforeMask = $initial; targetMask = $target; effectiveMask = $effective; status = $status; error = $errorText
prerequisites = $policy.prerequisites; evidence = $policy.evidence; sourceIds = @($policy.sourceIds)
}
}
[pscustomobject]@{
profile = $Plan.profile; version = $Plan.version; scope = $Plan.scope; role = $Plan.role; build = $Plan.build
schemaSha256 = $Plan.schemaSha256; provenance = $Plan.provenance
success = (@($results | Where-Object { $_.status -eq 'Failed' }).Count -eq 0)
results = @($results)
}
}
Export-ModuleMember -Function Import-WelaAuditProfiles, Format-WelaAuditMask, Get-WelaAuditProfilePlan, Get-WelaEffectiveAuditPolicy, Set-WelaEffectiveAuditPolicy, Get-WelaHostContext, Assert-WelaAuditProfileTarget, Invoke-WelaAuditProfilePlan
+414
View File
@@ -0,0 +1,414 @@
# Execution helpers for configure. Compatible with Windows PowerShell 5.1.
function Invoke-WelaNative {
param([string]$FilePath, [string[]]$Arguments)
# Windows PowerShell sends native stderr through the error stream. Collect it
# without treating stderr alone as failure; the process exit code is decisive.
$ErrorActionPreference = 'Continue'
$PSNativeCommandUseErrorActionPreference = $false
$null = Get-Command $FilePath -ErrorAction Stop
$global:LASTEXITCODE = $null
$output = @(& $FilePath @Arguments 2>&1)
$exitCode = $global:LASTEXITCODE # Capture immediately, before invoking anything else.
$diagnostic = ($output | ForEach-Object { $_.ToString() }) -join [Environment]::NewLine
if ($null -eq $exitCode -or $exitCode -ne 0) {
throw "$FilePath $($Arguments -join ' ') failed (exit: $exitCode). $diagnostic"
}
[pscustomobject]@{ ExitCode = $exitCode; Output = $output; Diagnostic = $diagnostic }
}
function New-WelaConfigurationContext {
param([switch]$Auto, [switch]$DryRun, [string]$BackupPath)
if (-not $DryRun) {
if (-not $BackupPath) {
$BackupPath = Join-Path $script:ScriptRoot ("wela-backup-{0}-{1}" -f (Get-Date -Format 'yyyyMMdd-HHmmss'), [guid]::NewGuid().ToString('N'))
}
# Refuse reuse: a prior run's recovery evidence must never be overwritten.
$null = New-Item -ItemType Directory -Path $BackupPath -ErrorAction Stop
$BackupPath = (Resolve-Path -LiteralPath $BackupPath -ErrorAction Stop).Path
}
[pscustomobject]@{
Auto = [bool]$Auto; DryRun = [bool]$DryRun; BackupPath = $BackupPath
Results = New-Object 'System.Collections.Generic.List[object]'
Checks = New-Object 'System.Collections.Generic.List[object]'
}
}
function Invoke-WelaConfigurationControl {
param($Context, [string]$Id, [string]$Kind, $Target, $Desired,
[scriptblock]$Read, [scriptblock]$Compliant, [scriptblock]$Apply,
[string]$Description = '', [scriptblock]$PreserveWhen, $CallbackState)
$result = [pscustomobject][ordered]@{
Id = $Id; Kind = $Kind; Target = $Target; Desired = $Desired
Before = $null; After = $null; Status = 'Failed'; Diagnostic = ''
}
try {
$result.Before = & $Read $CallbackState
$preserveReason = if ($PreserveWhen) { & $PreserveWhen $result.Before } else { $null }
if ($preserveReason) {
$result.Status = 'Skipped'; $result.After = $result.Before; $result.Diagnostic = [string]$preserveReason
} elseif (& $Compliant $result.Before $CallbackState) {
$result.Status = 'AlreadyCompliant'
$result.After = $result.Before
} elseif ($Context.DryRun) {
$result.Status = 'Skipped'; $result.Diagnostic = 'Dry run: change required; no write or restart performed.'
} else {
$proceed = $Context.Auto
if (-not $proceed) {
$response = Read-Host "$Id : $Description Apply this change? (Y/n)"
$proceed = ($response -eq '' -or $response -match '^[Yy]$')
}
if (-not $proceed) {
$result.Status = 'Skipped'; $result.Diagnostic = 'Declined by operator.'
} else {
# Persist the exact pre-change value before any mutation. A journal
# failure stops this control, including service restarts.
$entry = [ordered]@{
Version = 1; ComputerName = $env:COMPUTERNAME
RecordedUtc = [DateTime]::UtcNow.ToString('o')
Id = $Id; Kind = $Kind; Target = $Target
Before = $result.Before; Desired = $Desired
}
$entry | ConvertTo-Json -Depth 12 -Compress |
Add-Content -LiteralPath (Join-Path $Context.BackupPath 'before.jsonl') -Encoding UTF8 -ErrorAction Stop
$applied = @(& $Apply $CallbackState)
$result.Diagnostic = ($applied | ForEach-Object {
if ($_.PSObject.Properties['Diagnostic']) { $_.Diagnostic } else { $_.ToString() }
}) -join [Environment]::NewLine
$result.After = & $Read $CallbackState
if (-not (& $Compliant $result.After $CallbackState)) {
throw "Post-apply verification did not match the requested state. $($result.Diagnostic)"
}
$result.Status = 'Applied'
}
}
if ($result.Status -in @('Applied', 'AlreadyCompliant')) {
$Context.Checks.Add([pscustomobject]@{ Result = $result; Read = $Read; Compliant = $Compliant; CallbackState = $CallbackState })
}
} catch {
$result.Status = 'Failed'; $result.Diagnostic = $_.ToString()
}
$Context.Results.Add($result)
$color = if ($result.Status -eq 'Failed') { 'Red' } elseif ($result.Status -eq 'Skipped') { 'Yellow' } else { 'Green' }
Write-Host "[$($result.Status)] $Id $($result.Diagnostic)" -ForegroundColor $color
}
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
[ValidateSet("native-windows-configuration", "advanced-audit-policy-only")]
[string]$Scope = "native-windows-configuration")
# A second read detects a value that was compliant earlier but changed during
# this run. It does not establish whether GPO or another writer caused drift.
foreach ($check in $Context.Checks) {
try {
$check.Result.After = & $check.Read $check.CallbackState
if (-not (& $check.Compliant $check.Result.After $check.CallbackState)) {
$check.Result.Status = 'Overridden'
$check.Result.Diagnostic = 'State was compliant earlier but changed before the final check; cause unknown.'
}
} catch {
$check.Result.Status = 'Failed'
$check.Result.Diagnostic = "Final verification failed: $_"
}
}
$failed = @($Context.Results | Where-Object { $_.Status -in @('Failed', 'Overridden') }).Count
$skipped = @($Context.Results | Where-Object { $_.Status -eq 'Skipped' }).Count
$report = [pscustomobject][ordered]@{
ExitCode = $(if ($failed) { 1 } else { 0 }); DryRun = $Context.DryRun
BackupPath = $Context.BackupPath; Failed = $failed; Skipped = $skipped; Scope = $Scope
Results = @($Context.Results.ToArray())
}
if ($Plan) {
$report | Add-Member NoteProperty Profile $Plan.profile
$report | Add-Member NoteProperty Version $Plan.version
$report | Add-Member NoteProperty Role $Plan.role
$report | Add-Member NoteProperty Build $Plan.build
$report | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256
$report | Add-Member NoteProperty Provenance $Plan.provenance
$report | Add-Member NoteProperty ProfileScope $Plan.scope
}
if ($ResultsPath) {
try { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
catch { $report.ExitCode = 1; Write-Host "[Failed] Writing results: $_" -ForegroundColor Red }
}
if ($report.ExitCode) { Write-Host "Configuration incomplete: $failed failed or overridden control(s). Review results and recovery journal." -ForegroundColor Red }
elseif ($Context.DryRun) { Write-Host 'Dry run completed. No Windows configuration was changed.' -ForegroundColor Cyan }
elseif ($skipped) { Write-Host "Configuration completed with $skipped skipped control(s)." -ForegroundColor Yellow }
else { Write-Host 'Configuration completed; all requested controls verified.' -ForegroundColor Green }
return $report
}
function Set-WelaEventLogControl {
param($Context, [string]$Log, [string]$Property, $Desired)
$state = @{ Log = $Log; Property = $Property; Desired = $Desired }
# Explicit callback state preserves values for the final recheck without
# GetNewClosure's dynamic-module scope, which hides script-local helpers in 5.1.
$read = { param($state) (Get-WinEvent -ListLog $state.Log -ErrorAction Stop).($state.Property) }
$test = {
param($value, $state)
if ($state.Property -eq 'MaximumSizeInBytes') { return $value -ge $state.Desired }
return $value -eq $state.Desired
}
$apply = {
param($state)
$argument = if ($state.Property -eq 'MaximumSizeInBytes') { "/ms:$($state.Desired)" } else { '/e:true' }
Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments @('sl', $state.Log, $argument)
}
Invoke-WelaConfigurationControl -Context $Context -Id "EventLog/$Log/$Property" -Kind EventLog `
-Target @{ Log = $Log; Property = $Property } -Desired $Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state
}
function Get-WelaRegistryState {
param([string]$Path, [string]$Name)
if (-not (Test-Path -LiteralPath $Path -ErrorAction Stop)) {
return [pscustomobject]@{ KeyExists = $false; ValueExists = $false; Value = $null; Type = $null }
}
$key = Get-Item -LiteralPath $Path -ErrorAction Stop
if ($key.GetValueNames() -notcontains $Name) {
return [pscustomobject]@{ KeyExists = $true; ValueExists = $false; Value = $null; Type = $null }
}
[pscustomobject]@{
KeyExists = $true; ValueExists = $true
Value = $key.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
Type = $key.GetValueKind($Name).ToString()
}
}
function New-WelaRegistryKey {
param([string]$Path)
if (Test-Path -LiteralPath $Path -ErrorAction Stop) { return }
$separator = $Path.TrimEnd('\').LastIndexOf('\')
if ($separator -lt 1) { throw "Registry root is unavailable: $Path" }
$parent = $Path.Substring(0, $separator)
# Registry New-Item without Force requires its immediate parent. Build only
# missing ancestors; never run New-Item -Force against an existing key.
New-WelaRegistryKey -Path $parent
$null = New-Item -Path $Path -ErrorAction Stop
}
function Set-WelaRegistryControl {
param($Context, [string]$Path, [string]$Name, $Value, [string]$Type = 'DWord', [scriptblock]$PreserveWhen)
$state = @{ Path = $Path; Name = $Name; Value = $Value; Type = $Type; PreserveWhen = $PreserveWhen }
$read = { param($state) Get-WelaRegistryState -Path $state.Path -Name $state.Name }
$test = { param($value, $state) $value.ValueExists -and $value.Value -eq $state.Value -and $value.Type -eq $state.Type }
$apply = {
param($state)
New-WelaRegistryKey -Path $state.Path
if ($state.PreserveWhen) {
# Recheck after the prompt and journal, immediately before the value write.
$fresh = Get-WelaRegistryState -Path $state.Path -Name $state.Name
$preserveReason = & $state.PreserveWhen $fresh
if ($preserveReason) { throw "Refused registry write after state changed: $preserveReason" }
}
Set-ItemProperty -LiteralPath $state.Path -Name $state.Name -Value $state.Value -Type $state.Type -ErrorAction Stop
}
Invoke-WelaConfigurationControl -Context $Context -Id "Registry/$Path/$Name" -Kind Registry `
-Target @{ Path = $Path; Name = $Name } -Desired @{ Value = $Value; Type = $Type } `
-Read $read -Compliant $test -Apply $apply -PreserveWhen $PreserveWhen -CallbackState $state
}
function Initialize-WelaConfigurationAuditApi {
if ('Wela.ConfigurationAuditApi' -as [type]) { return }
# Querying the Windows API avoids localized auditpol /get CSV (six columns;
# unlike /backup output, it has no numeric Setting Value column).
Add-Type -TypeDefinition @'
using System;
using System.ComponentModel;
using System.Runtime.InteropServices;
namespace Wela {
public static class ConfigurationAuditApi {
[StructLayout(LayoutKind.Sequential)]
private struct AuditPolicyInformation {
public Guid Subcategory;
public UInt32 Information;
public Guid Category;
}
[DllImport("advapi32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.U1)]
private static extern bool AuditQuerySystemPolicy(
[In] Guid[] subcategories, UInt32 count, out IntPtr policy);
[DllImport("advapi32.dll")]
private static extern void AuditFree(IntPtr buffer);
public static UInt32 Query(Guid subcategory) {
IntPtr buffer = IntPtr.Zero;
if (!AuditQuerySystemPolicy(new Guid[] { subcategory }, 1, out buffer)) {
throw new Win32Exception(Marshal.GetLastWin32Error());
}
try {
if (buffer == IntPtr.Zero) throw new InvalidOperationException("Audit policy query returned no buffer.");
AuditPolicyInformation policy = (AuditPolicyInformation)Marshal.PtrToStructure(buffer, typeof(AuditPolicyInformation));
if (policy.Subcategory != subcategory) throw new InvalidOperationException("Audit policy query returned a different subcategory.");
return policy.Information;
} finally {
if (buffer != IntPtr.Zero) AuditFree(buffer);
}
}
}
}
'@ -ErrorAction Stop
}
function Get-WelaNativeAuditPolicy {
param([string]$Guid)
Initialize-WelaConfigurationAuditApi
return [Wela.ConfigurationAuditApi]::Query([guid]$Guid)
}
function Get-WelaAuditPolicyMask {
param([string]$Guid)
$flags = Get-WelaNativeAuditPolicy -Guid $Guid
if ($flags -notin @(0, 1, 2, 3, 4)) { throw "Unexpected audit policy flags $flags for $Guid." }
# POLICY_AUDIT_EVENT_NONE is 4; the success/failure mask is zero.
return [int]($flags -band 3)
}
function Set-WelaAuditPolicyControl {
param($Context, $Policy, [ValidateRange(0, 3)][int]$Mask = 3,
[ValidateSet('exact', 'minimum')][string]$Mode = 'exact')
$guid = $Policy.GUID
$state = @{ Guid = $guid; Mask = $Mask; Mode = $Mode }
$read = { param($state) Get-WelaAuditPolicyMask -Guid $state.Guid }
$test = {
param($value, $state)
if ($state.Mode -eq 'minimum') { return ($value -band $state.Mask) -eq $state.Mask }
return $value -eq $state.Mask
}
$apply = {
param($state)
$arguments = @('/set', "/subcategory:{$($state.Guid)}")
if ($state.Mode -eq 'minimum') {
# Only enable required flags: never disable another writer's added flag.
if ($state.Mask -band 1) { $arguments += '/success:enable' }
if ($state.Mask -band 2) { $arguments += '/failure:enable' }
} else {
$success = if ($state.Mask -band 1) { 'enable' } else { 'disable' }
$failure = if ($state.Mask -band 2) { 'enable' } else { 'disable' }
$arguments += "/success:$success", "/failure:$failure"
}
Invoke-WelaNative -FilePath 'auditpol.exe' -Arguments $arguments
}
Invoke-WelaConfigurationControl -Context $Context -Id "AuditPolicy/$($Policy.Name)" -Kind AuditPolicy `
-Target @{ Guid = $guid } -Desired @{ Mask = $Mask; Mode = $Mode } -Read $read -Compliant $test -Apply $apply -CallbackState $state
}
function Set-WelaProfileAuditControls {
param($Context, $Plan)
# The caller must complete Assert-WelaAuditProfileTarget before any mutations.
foreach ($policy in $Plan.policies) {
if ($policy.mode -notin @('exact', 'minimum') -and -not ($policy.mode -eq 'optional' -and $Plan.includeOptional)) { continue }
$mode = if ($policy.mode -eq 'minimum') { 'minimum' } else { 'exact' }
Set-WelaAuditPolicyControl -Context $Context -Policy @{ GUID = $policy.guid; Name = $policy.id } -Mask $policy.requiredMask -Mode $mode
$row = $Context.Results[$Context.Results.Count - 1]
$row | Add-Member NoteProperty Profile $Plan.profile
$row | Add-Member NoteProperty Version $Plan.version
$row | Add-Member NoteProperty SchemaSha256 $Plan.schemaSha256
$row | Add-Member NoteProperty Role $Plan.role
$row | Add-Member NoteProperty Build $Plan.build
$row | Add-Member NoteProperty Mode $policy.mode
$row | Add-Member NoteProperty Prerequisites $policy.prerequisites
$row | Add-Member NoteProperty Evidence $policy.evidence
$row | Add-Member NoteProperty SourceIds $policy.sourceIds
$row | Add-Member NoteProperty Note $policy.note
}
}
function Set-WelaCertificateAuditControl {
param($Context)
$root = 'HKLM:\SYSTEM\CurrentControlSet\Services\CertSvc\Configuration'
try {
if (-not (Test-Path -LiteralPath $root -ErrorAction Stop)) {
$Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Skipped'; Diagnostic = 'No configured local CA.' })
return
}
$caName = (Get-ItemProperty -LiteralPath $root -Name Active -ErrorAction Stop).Active
if (-not $caName) { throw 'CA configuration has no active CA name.' }
$path = Join-Path $root $caName
$state = @{ Path = $path }
$read = {
param($state)
[pscustomobject]@{
Registry = Get-WelaRegistryState -Path $state.Path -Name AuditFilter
ServiceStatus = (Get-Service -Name CertSvc -ErrorAction Stop).Status.ToString()
}
}
$test = { param($value) $value.Registry.ValueExists -and $value.Registry.Value -eq 127 -and $value.Registry.Type -eq 'DWord' -and $value.ServiceStatus -eq 'Running' }
$apply = {
$state = Get-Service -Name CertSvc -ErrorAction Stop
if ($state.Status -ne 'Running') { throw 'CertSvc is not running; refusing to start a previously stopped CA. Start it deliberately before retrying.' }
Invoke-WelaNative -FilePath 'certutil.exe' -Arguments @('-setreg', 'CA\AuditFilter', '127')
Restart-Service -Name CertSvc -Force -ErrorAction Stop
$service = Get-Service -Name CertSvc -ErrorAction Stop
$service.WaitForStatus([System.ServiceProcess.ServiceControllerStatus]::Running, [TimeSpan]::FromSeconds(30))
}
Invoke-WelaConfigurationControl -Context $Context -Id 'ADCS/AuditFilter' -Kind CertificateService `
-Target @{ Path = $path; Name = 'AuditFilter'; Service = 'CertSvc' } -Desired 127 `
-Read $read -Compliant $test -Apply $apply -Description 'Set AuditFilter=127 and restart Certificate Services.' -CallbackState $state
} catch {
$Context.Results.Add([pscustomobject]@{ Id = 'ADCS/AuditFilter'; Kind = 'CertificateService'; Target = $root; Desired = 127; Before = $null; After = $null; Status = 'Failed'; Diagnostic = $_.ToString() })
}
}
function Set-WelaNtlmConfigurationControl {
[CmdletBinding(SupportsShouldProcess = $true)]
param(
$Context,
[ValidateSet('Outgoing', 'Domain')][string]$Scope,
[ValidateSet('PreserveOrAudit', 'Audit', 'Deny')][string]$Mode = 'PreserveOrAudit'
)
$path = if ($Scope -eq 'Outgoing') { 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0' } else { 'HKLM:\SYSTEM\CurrentControlSet\Services\Netlogon\Parameters' }
$name = if ($Scope -eq 'Outgoing') { 'RestrictSendingNTLMTraffic' } else { 'AuditNTLMInDomain' }
$desired = if ($Scope -eq 'Domain') { 7 } elseif ($Mode -eq 'Deny') { 2 } else { 1 }
$id = "Registry/$path/$name"
$state = $null
$skipReason = ''
$status = 'Skipped'
try {
$state = if ($Scope -eq 'Outgoing') { Get-WelaOutgoingNtlmState } else { Get-WelaDomainNtlmState }
Write-Host "$Scope NTLM: $($state.Description)"
if ($Scope -eq 'Outgoing') { Write-Host "Policy source: $($state.PolicySource)" }
if ($Scope -eq 'Domain' -and -not $state.Applicable) {
if ($state.Description -notlike 'Not applicable*') { throw "Domain NTLM applicability is unknown: $($state.Description)" }
$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.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.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') {
Write-Warning 'Explicit Deny mode can break NTLM authentication. This is enforcement, not audit-only configuration.'
}
if (-not $PSCmdlet.ShouldProcess($id, "Set $Scope NTLM policy to $desired")) {
$skipReason = 'ShouldProcess declined the NTLM change.'
} else {
# Shared runner owns prompts, dry-run suppression, exact registry
# before-state journal, type/value read-back and final drift check.
$preserve = $null
if ($Scope -eq 'Outgoing' -and $Mode -eq 'PreserveOrAudit') {
$preserve = {
param($snapshot)
if ($snapshot.ValueExists -and $snapshot.Type -eq 'DWord' -and $snapshot.Value -eq 2) {
return 'Preserved newly observed Deny all enforcement (2); explicit Audit mode is required to replace it.'
}
if ($snapshot.ValueExists -and ($snapshot.Type -ne 'DWord' -or $snapshot.Value -notin @(0, 1, 2))) {
return "Preserved newly observed unknown outgoing NTLM value/type ($($snapshot.Value)/$($snapshot.Type))."
}
}
}
Set-WelaRegistryControl -Context $Context -Path $path -Name $name -Value $desired -PreserveWhen $preserve
return
}
}
} catch {
$status = 'Failed'
$skipReason = $_.ToString()
}
$Context.Results.Add([pscustomobject][ordered]@{
Id = $id; Kind = 'Registry'; Target = @{ Path = $path; Name = $name }
Desired = @{ Value = $desired; Type = 'DWord' }; Before = $state; After = $state
Status = $status; Diagnostic = $skipReason
})
$color = if ($status -eq 'Failed') { 'Red' } else { 'Yellow' }
Write-Host "[$status] $id $skipReason" -ForegroundColor $color
}
+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)."
+8 -1
View File
@@ -17,6 +17,9 @@ function Assert-Equal($Actual, $Expected, [string]$Message) {
function TestAdministrator { return $true }
function CollectAuditpol { param([switch]$UseCached) return $true }
function GetAuditpol { return @{} }
function Get-WelaOutgoingNtlmState {
return [pscustomobject]@{ Description = 'Audit all (1)'; PolicySource = 'Mocked policy source' }
}
function Get-WelaDomainNtlmState { return [pscustomobject]@{ Description = $script:description } }
function Export-MitreHeatmap { param($sigmaRules, $OutputPath, $UseIdealCount) }
function BuildAuditResult {
@@ -48,7 +51,7 @@ try {
)) {
$script:description = $observed
$output = (AuditLogSetting -outType std -Baseline YamatoSecurity 6>&1 | Out-String)
$expectedHeading = 'NTLM Authentication: ' + $observed
$expectedHeading = 'NTLM Authentication: Audit all (1); ' + $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'
@@ -56,6 +59,10 @@ try {
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'
$outgoingRow = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'WELA-Audit-Result.csv') | Where-Object SubCategory -eq 'Outgoing NTLM policy')
Assert-Equal $outgoingRow.Count 1 'CSV contains one outgoing NTLM setting row'
Assert-Equal $outgoingRow[0].CurrentSetting 'Audit all (1)' 'CSV retains the independent outgoing NTLM state'
Assert-Equal $outgoingRow[0].RuleCount '0' 'Outgoing 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'
}
@@ -0,0 +1,209 @@
# Composed #362/#363/#365 behavior, using mock registry/CIM and temporary journals only.
$ErrorActionPreference = 'Stop'
# Keep mocks in the same script scope as dot-sourced helpers/imported commands;
# Windows PowerShell 5.1 resolves script-local originals ahead of global mocks.
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
. (Join-Path $repo 'scripts/Configuration.ps1')
$tokens = $null; $errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw ($errors | Out-String) }
foreach ($name in @('Get-WelaOutgoingNtlmPolicySource', 'Get-WelaOutgoingNtlmState', 'Set-WelaOutgoingNtlmPolicy', 'Get-WelaDomainNtlmState', 'Set-WelaDomainNtlmAudit')) {
$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true)
. ([scriptblock]::Create($function.Extent.Text))
}
$script:assertions = 0
$script:contexts = New-Object 'System.Collections.Generic.List[object]'
function Assert($Condition, [string]$Message) {
if (-not $Condition) { throw "FAIL: $Message" }
$script:assertions++
}
function New-TestContext([switch]$DryRun) {
$path = Join-Path ([IO.Path]::GetTempPath()) ('wela-ntlm-integration-' + [guid]::NewGuid().ToString('N'))
$context = New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath $path
$script:contexts.Add($context)
$script:currentContext = $context
return $context
}
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
}
function Get-CimInstance {
param($ClassName, $Property, $Namespace, $ErrorAction)
if ($ClassName -eq 'Win32_OperatingSystem') {
if ($script:roleFails) { throw 'Mock role query failure' }
return [pscustomobject]@{ ProductType = $script:productType }
}
}
function Test-Path {
param($LiteralPath, $Path, $ErrorAction)
$target = if ($LiteralPath) { $LiteralPath } else { $Path }
if ($target -like 'HKLM:*') { return $true }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $target
}
function Get-ItemProperty {
param($LiteralPath, $ErrorAction)
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 = $script:registryTypes[$Name] }
}
function Set-ItemProperty {
param($LiteralPath, $Name, $Value, $Type, $ErrorAction)
# Assert the actual mutation cannot run before its matching journal entry.
$journal = Join-Path $script:currentContext.BackupPath 'before.jsonl'
if (-not (Microsoft.PowerShell.Management\Test-Path -LiteralPath $journal)) { throw 'Mutation occurred before journal existed' }
$entries = @(Get-Content -LiteralPath $journal | ConvertFrom-Json)
if ($entries[-1].Target.Name -ne $Name) { throw 'Mutation occurred before its own journal entry' }
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
$context = New-TestContext -DryRun
Set-WelaOutgoingNtlmPolicy -Context $context
Set-WelaDomainNtlmAudit -Context $context
Assert ($script:writes -eq 0) 'Both NTLM controls honor shared DryRun'
Assert ($context.Results.Count -eq 2 -and @($context.Results | Where-Object Status -ne Skipped).Count -eq 0) 'Both dry-run changes are reported as skipped'
Assert (-not (Microsoft.PowerShell.Management\Test-Path -LiteralPath $context.BackupPath)) 'NTLM dry run creates no journal or backup directory'
Reset-Mocks 2
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context
Assert ($script:writes -eq 0 -and $script:registry.RestrictSendingNTLMTraffic -eq 2) 'Context Auto preserves existing deny'
Assert ($context.Results[0].Status -eq 'Skipped' -and $context.Results[0].Diagnostic -match 'Deny all enforcement') 'Preserved enforcement is explicit in results'
Reset-Mocks 42
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Skipped') 'Unknown outgoing value is preserved and reported'
Reset-Mocks
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context
Set-WelaDomainNtlmAudit -Context $context
$result = Complete-WelaConfiguration -Context $context
Assert ($script:registry.RestrictSendingNTLMTraffic -eq 1 -and $script:registry.AuditNTLMInDomain -eq 7) 'Context applies audit-only outgoing and DC Enable all'
Assert ($script:writes -eq 2 -and $result.ExitCode -eq 0) 'Both actual writes are verified successfully'
$journal = @(Get-Content -LiteralPath (Join-Path $context.BackupPath 'before.jsonl') | ConvertFrom-Json)
Assert ($journal.Count -eq 2 -and $journal[0].Before.Value -eq 0 -and $journal[1].Before.Value -eq 2) 'Journal records exact values before both NTLM changes'
Assert ($journal[0].Before.Type -eq 'DWord' -and $journal[1].Desired.Value -eq 7) 'Journal retains registry type and requested domain value'
Set-WelaOutgoingNtlmPolicy -Context $context
Set-WelaDomainNtlmAudit -Context $context
Assert ($script:writes -eq 2) 'Verified NTLM controls are idempotent'
$script:registry.AuditNTLMInDomain = 0
Assert ((Complete-WelaConfiguration $context).ExitCode -eq 1) 'Final verification aggregates later NTLM drift'
Reset-Mocks 2
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context -Mode Audit
Assert ($script:registry.RestrictSendingNTLMTraffic -eq 1) 'Explicit Audit override goes through shared journal and verification'
Set-WelaOutgoingNtlmPolicy -Context $context -Mode Deny
Assert ($script:registry.RestrictSendingNTLMTraffic -eq 2) 'Explicit Deny remains a separate operator choice'
Reset-Mocks 0 2 3
$context = New-TestContext
Set-WelaDomainNtlmAudit -Context $context
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Skipped') 'Non-DC domain policy is skipped'
Assert ($context.Results[0].Diagnostic -match 'Not applicable') 'Non-DC reason is explicit'
Reset-Mocks
$script:roleFails = $true
$context = New-TestContext
Set-WelaDomainNtlmAudit -Context $context
Set-WelaOutgoingNtlmPolicy -Context $context
$result = Complete-WelaConfiguration $context
Assert ($result.ExitCode -eq 1 -and $result.Failed -eq 1) 'Unknown role produces an aggregated failure'
Assert ($script:registry.RestrictSendingNTLMTraffic -eq 1) 'Other controls continue after unknown domain role'
Reset-Mocks
$script:readFails = $true
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context
Set-WelaDomainNtlmAudit -Context $context
$result = Complete-WelaConfiguration $context
Assert ($result.ExitCode -eq 1 -and $result.Failed -eq 2 -and $script:writes -eq 0) 'Both unreadable NTLM states aggregate as failures with no writes'
Reset-Mocks
$script:writeFails = 'RestrictSendingNTLMTraffic'
$context = New-TestContext
Set-WelaOutgoingNtlmPolicy -Context $context
Set-WelaDomainNtlmAudit -Context $context
$result = Complete-WelaConfiguration $context
Assert ($result.ExitCode -eq 1 -and $result.Failed -eq 1) 'NTLM write failure aggregates in final exit code'
Assert ($script:registry.AuditNTLMInDomain -eq 7) 'A failed outgoing control does not prevent domain configuration'
Reset-Mocks
$context = New-TestContext
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) {
if (Microsoft.PowerShell.Management\Test-Path -LiteralPath $context.BackupPath) {
Remove-Item -LiteralPath $context.BackupPath -Recurse -Force
}
}
}
@@ -0,0 +1,154 @@
# Profile command + verified configuration integration. No Windows policy is touched.
$ErrorActionPreference = 'Stop'
# Keep mocks in the same script scope as dot-sourced helpers/imported commands;
# Windows PowerShell 5.1 resolves script-local originals ahead of global mocks.
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
. (Join-Path $repo 'scripts/Configuration.ps1')
$tokens = $null; $errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw ($errors | Out-String) }
foreach ($name in @('Get-WelaSelectedContext', 'Show-WelaAuditProfilePrerequisites', 'Invoke-WelaProfileCommand', 'ConfigureAuditSettings')) {
$function = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true)
. ([scriptblock]::Create($function.Extent.Text))
}
$script:assertions = 0
$script:cleanup = New-Object 'System.Collections.Generic.List[string]'
$data = Import-WelaAuditProfiles
$zero = @{}
foreach ($policy in $data.catalog) { $zero[$policy.guid] = 0 }
$shareGuid = ($data.catalog | Where-Object id -eq 'Detailed File Share').guid
$processGuid = ($data.catalog | Where-Object id -eq 'Process Creation').guid
$privilegeGuid = ($data.catalog | Where-Object id -eq 'Sensitive Privilege Use').guid
function Assert($Condition, [string]$Message) {
if (-not $Condition) { throw "FAIL: $Message" }
$script:assertions++
}
function Assert-Throws([scriptblock]$Action, [string]$Pattern) {
$caught = ''
try { & $Action | Out-Null } catch { $caught = $_.ToString() }
Assert ($caught -match $Pattern) "Expected failure matching '$Pattern', got '$caught'"
}
function Reset-Run([string]$Profile = 'cis-win11-v4-l1', [switch]$DryRun) {
$script:state = $zero.Clone()
$script:writes = @()
$script:failGuid = ''
$script:concurrentGuid = ''
$script:Profile = $Profile
$script:Role = 'Client'; $script:Build = 26100
$script:hostBuild = 26100
$script:Baseline = $null; $script:IncludeOptional = $false
$script:Auto = $true; $script:DryRun = [bool]$DryRun
$script:BackupPath = Join-Path ([IO.Path]::GetTempPath()) ('wela-profile-integration-' + [guid]::NewGuid().ToString('N'))
$script:ResultsPath = $script:BackupPath + '-results.json'
$script:PlanPath = $null
$script:cleanup.Add($script:BackupPath)
$script:cleanup.Add($script:ResultsPath)
}
function TestWindows { return $true }
function TestAdministrator { return $true }
function Get-WelaHostContext { [pscustomobject]@{ Role = 'Client'; Build = $script:hostBuild } }
function Get-WelaEffectiveAuditPolicy { return $script:state.Clone() }
function Get-WelaNativeAuditPolicy {
param($Guid)
if (-not $script:state.ContainsKey($Guid)) { throw "Mock missing policy: $Guid" }
return $script:state[$Guid]
}
function Invoke-WelaNative {
param($FilePath, $Arguments)
if ($FilePath -ne 'auditpol.exe' -or $Arguments[0] -ne '/set') { throw 'Unexpected native mutation' }
$guid = ($Arguments | Where-Object { $_ -like '/subcategory:*' }) -replace '^/subcategory:\{([^}]+)\}$', '$1'
$journal = Join-Path $script:BackupPath 'before.jsonl'
if (-not (Test-Path -LiteralPath $journal)) { throw 'Audit mutation occurred before journal' }
$entries = @(Get-Content -LiteralPath $journal | ConvertFrom-Json)
if ($entries[-1].Target.Guid -ne $guid) { throw 'Audit mutation occurred before matching journal entry' }
if ($guid -eq $script:failGuid) { throw 'Mock auditpol write failure' }
# Simulate a concurrent writer enabling Failure after WELA observed the policy.
if ($guid -eq $script:concurrentGuid) { $script:state[$guid] = $script:state[$guid] -bor 2 }
$mask = $script:state[$guid]
foreach ($arg in $Arguments) {
switch ($arg) {
'/success:enable' { $mask = $mask -bor 1 }
'/success:disable' { $mask = $mask -band 2 }
'/failure:enable' { $mask = $mask -bor 2 }
'/failure:disable' { $mask = $mask -band 1 }
}
}
$script:state[$guid] = $mask
$script:writes += [pscustomobject]@{ Guid = $guid; Arguments = $Arguments }
[pscustomobject]@{ ExitCode = 0; Diagnostic = ''; Output = @() }
}
try {
Reset-Run -DryRun
Invoke-WelaProfileCommand configure | Out-Null
$report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($script:writes.Count -eq 0 -and $report.DryRun) 'configure -Profile -DryRun makes no audit writes'
Assert ($report.Scope -eq 'advanced-audit-policy-only' -and $report.ProfileScope -eq 'advanced-audit-policy-only') 'Profile-only results declare their narrower scope'
Assert (-not (Test-Path -LiteralPath $script:BackupPath)) 'Profile dry run creates no journal directory'
Assert ($report.Results.Count -gt 0 -and @($report.Results | Where-Object Status -ne Skipped).Count -eq 0) 'Profile dry-run proposals remain explicit skipped results'
Reset-Run
$script:state[$shareGuid] = 1
$script:concurrentGuid = $processGuid
Invoke-WelaProfileCommand configure | Out-Null
$report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($script:state[$shareGuid] -eq 3) 'Minimum Failure preserves existing Success'
Assert ($script:state[$processGuid] -eq 3) 'Minimum Success preserves concurrently added Failure'
$processWrite = $script:writes | Where-Object Guid -eq $processGuid
Assert ($processWrite.Arguments -contains '/success:enable' -and @($processWrite.Arguments | Where-Object { $_ -like '*:disable' }).Count -eq 0) 'Minimum native command only enables required bits'
Assert ($report.ExitCode -eq 0) 'Minimum supersets pass final compliance verification'
Assert ($report.Profile -eq 'cis-win11-v4-l1' -and $report.Role -eq 'Client' -and $report.Build -eq 26100) 'Final report retains profile role and build'
Assert ($report.Version -and $report.SchemaSha256.Length -eq 64 -and $report.Provenance.Count -gt 0) 'Final report retains version and provenance'
$row = $report.Results | Where-Object { $_.Target.Guid -eq $shareGuid }
Assert ($row.Mode -eq 'minimum' -and $row.Evidence -and $row.SourceIds.Count -gt 0) 'Per-control mode and source evidence survive the context adapter'
$journal = @(Get-Content -LiteralPath (Join-Path $script:BackupPath 'before.jsonl') | ConvertFrom-Json)
Assert (@($journal | Where-Object { $_.Target.Guid -eq $shareGuid -and $_.Before -eq 1 -and $_.Desired.Mask -eq 2 -and $_.Desired.Mode -eq 'minimum' }).Count -eq 1) 'Minimum policy journal preserves before state and requirement semantics'
Reset-Run 'microsoft-sct-win11-24h2'
$script:state[$privilegeGuid] = 3
Invoke-WelaProfileCommand configure | Out-Null
Assert ($script:state[$privilegeGuid] -eq 1) 'Exact SCT mask remains exact rather than hardcoded SF'
$privilegeWrite = $script:writes | Where-Object Guid -eq $privilegeGuid
Assert ($privilegeWrite.Arguments -contains '/failure:disable') 'Exact Success deliberately clears unrequested Failure'
Reset-Run 'wela-2.2.0'
$script:IncludeOptional = $true
Invoke-WelaProfileCommand configure | Out-Null
$report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
$kernel = $report.Results | Where-Object Id -eq 'AuditPolicy/Kernel Object'
Assert ($kernel.Prerequisites -match 'SACL' -and $kernel.Evidence -and $kernel.SourceIds.Count -gt 0) 'Added native controls retain dependency and source evidence after apply'
Reset-Run
$script:failGuid = $processGuid
Assert-Throws { Invoke-WelaProfileCommand configure } 'advanced audit policies failed'
$report = Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($report.ExitCode -eq 1 -and $report.Failed -eq 1) 'Profile native failure reaches final machine-readable result'
Assert ($script:writes.Count -gt 0) 'Other policy controls continue after one failure'
Reset-Run 'windows-defaults-reviewed-2026-09'
Assert-Throws { Invoke-WelaProfileCommand configure } 'reference'
Assert ($script:writes.Count -eq 0 -and -not (Test-Path -LiteralPath $script:BackupPath)) 'Reference-only defaults fail before journal creation or mutation'
Reset-Run
$script:state.Remove($processGuid)
Assert-Throws { Invoke-WelaProfileCommand configure } 'unknown current'
Assert ($script:writes.Count -eq 0 -and -not (Test-Path -LiteralPath $script:BackupPath)) 'Missing required state refuses the entire profile before mutation'
Reset-Run
$script:hostBuild = 19045
Assert-Throws { ConfigureAuditSettings -Auto -BackupPath $script:BackupPath } 'does not support'
Assert ($script:writes.Count -eq 0 -and -not (Test-Path -LiteralPath $script:BackupPath)) 'Legacy configure rejects unsupported hosts before any control or journal'
$referencePlan = Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role Client -Build 26100 -Current $zero
$emptyContext = New-WelaConfigurationContext -DryRun
$broaderReport = Complete-WelaConfiguration -Context $emptyContext -Plan $referencePlan
Assert ($broaderReport.Scope -eq 'native-windows-configuration' -and $broaderReport.ProfileScope -eq 'advanced-audit-policy-only') 'Broad configure scope is not mislabeled as its audit-policy profile scope'
$engine = (Get-Process -Id $PID).Path
$helpOutput = & $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') configure -Profile cis-win11-v4-l1 -Help 2>&1
Assert ($LASTEXITCODE -eq 0 -and ($helpOutput -join ' ') -match 'Usage:.*-Profile') 'Profile configure help returns usage without entering the Windows mutation path'
Write-Host "PASS: $script:assertions profile configuration integration assertions (mocked; no Windows changes)."
} finally {
foreach ($path in $script:cleanup) {
if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
}
}
+134
View File
@@ -0,0 +1,134 @@
# Safety regressions use extracted dispatcher statements with stub mutators and mocked registry APIs.
# Never dot-source WELA or invoke configure-sacl/update-rules implementations from this test.
$ErrorActionPreference = 'Stop'
# Keep mocks in the same script scope as dot-sourced helpers/imported commands;
# Windows PowerShell 5.1 resolves script-local originals ahead of global mocks.
$repo = Split-Path $PSScriptRoot -Parent
$tokens = $null; $errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
if ($errors.Count) { throw ($errors | Out-String) }
$dispatch = $ast.Find({ param($node) $node -is [Management.Automation.Language.SwitchStatementAst] -and $node.Condition.Extent.Text -eq '$Cmd.ToLower()' }, $false)
$guard = $ast.EndBlock.Statements | Where-Object {
$_ -is [Management.Automation.Language.IfStatementAst] -and $_.Extent.Text -match '-DryRun is supported only by configure'
} | Select-Object -First 1
if (-not $guard -or $guard.Extent.StartOffset -ge $dispatch.Extent.StartOffset) { throw 'DryRun rejection guard must precede command dispatch.' }
$source = Get-Content -LiteralPath (Join-Path $repo 'WELA.ps1') -Raw
$dispatchOnly = [scriptblock]::Create($source.Substring($guard.Extent.StartOffset, $dispatch.Extent.EndOffset - $guard.Extent.StartOffset))
$script:assertions = 0
function Assert($Condition, [string]$Message) {
if (-not $Condition) { throw "FAIL: $Message" }
$script:assertions++
}
# These stubs are the only mutators visible to the extracted command dispatcher.
function Set-AuditSacl { param([switch]$Auto) $script:saclCalls++ }
function UpdateRules { $script:updateCalls++ }
function ConfigureAuditSettings { $script:configureCalls++; [pscustomobject]@{ ExitCode = 0 } }
function Invoke-WelaProfileCommand { param($Command) $script:profileCalls++ }
$Help = $false; $Auto = $true; $Baseline = $null; $Profile = $null; $Debug = $false
$BackupPath = $null; $ResultsPath = $null; $OutgoingNtlmMode = 'PreserveOrAudit'
foreach ($command in @('configure-sacl', 'update-rules')) {
$Cmd = $command; $DryRun = $true
$script:saclCalls = 0; $script:updateCalls = 0
$caught = ''
try { & $dispatchOnly | Out-Null } catch { $caught = $_.ToString() }
Assert ($caught -match '-DryRun is supported only by configure') "Unsupported DryRun for $command is rejected"
Assert ($script:saclCalls -eq 0 -and $script:updateCalls -eq 0) "DryRun rejection occurs before $command mutator"
$DryRun = $false
& $dispatchOnly | Out-Null
Assert (($script:saclCalls + $script:updateCalls) -eq 1) "Safe fixture would detect dispatch to $command without the guard"
}
$Cmd = 'configure'; $DryRun = $true; $script:configureCalls = 0
& $dispatchOnly | Out-Null
Assert ($script:configureCalls -eq 1) 'Supported configure DryRun still dispatches'
$Profile = 'wela-2.2.0'; $script:profileCalls = 0
& $dispatchOnly | Out-Null
Assert ($script:profileCalls -eq 1) 'Supported profile DryRun still dispatches'
. (Join-Path $repo 'scripts/Configuration.ps1')
$script:cleanup = New-Object 'System.Collections.Generic.List[string]'
function Reset-Race($Value = 0, [string]$Type = 'DWord') {
$script:value = $Value; $script:type = $Type
$script:writes = 0; $script:changeOnPrompt = $null; $script:changeAfterJournal = $null
$script:prewriteReadFails = $false; $script:journalWritten = $false
}
function New-RaceContext([switch]$Prompt) {
$path = Join-Path ([IO.Path]::GetTempPath()) ('wela-safety-' + [guid]::NewGuid().ToString('N'))
$script:cleanup.Add($path)
New-WelaConfigurationContext -Auto:(-not $Prompt) -BackupPath $path
}
function Get-WelaOutgoingNtlmState {
# The first display is deliberately stale; the shared runner must trust its own fresh read.
[pscustomobject]@{ Readable = $true; Value = 0; Type = 'DWord'; Description = 'Allow all (initial read)'; PolicySource = 'mock' }
}
function Get-WelaRegistryState {
param($Path, $Name)
if ($script:journalWritten -and $script:prewriteReadFails) { throw 'Mock prewrite read failure' }
[pscustomobject]@{ KeyExists = $true; ValueExists = $true; Value = $script:value; Type = $script:type }
}
function New-WelaRegistryKey { param($Path) }
function Set-ItemProperty {
param($LiteralPath, $Name, $Value, $Type, $ErrorAction)
$script:value = $Value; $script:type = $Type; $script:writes++
}
function Read-Host {
param($Prompt)
if ($null -ne $script:changeOnPrompt) { $script:value = $script:changeOnPrompt }
return 'Y'
}
function Add-Content {
param($LiteralPath, $Value, $Encoding, $ErrorAction)
process {
# Preserve real temporary recovery files; only the mocked policy state changes.
$text = if ($PSBoundParameters.ContainsKey('Value')) { $Value } else { $_ }
Microsoft.PowerShell.Management\Add-Content -LiteralPath $LiteralPath -Value $text -Encoding $Encoding -ErrorAction Stop
$script:journalWritten = $true
if ($null -ne $script:changeAfterJournal) { $script:value = $script:changeAfterJournal }
}
}
try {
foreach ($value in @(2, 42)) {
Reset-Race $value
$context = New-RaceContext
Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing -Mode PreserveOrAudit
Assert ($script:writes -eq 0 -and $script:value -eq $value) "Fresh Before value $value is preserved despite stale display"
Assert ($context.Results[0].Status -eq 'Skipped' -and $context.Results[0].Before.Value -eq $value) 'Preservation records actual fresh snapshot'
Assert (-not $script:journalWritten) 'Initially preserved value produces no mutation journal'
}
Reset-Race 0 String
$context = New-RaceContext
Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Skipped') 'Unknown registry type is preserved by default'
foreach ($changed in @(2, 42)) {
Reset-Race
$script:changeOnPrompt = $changed
$context = New-RaceContext -Prompt
Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing
Assert ($script:writes -eq 0 -and $script:value -eq $changed) "New value $changed introduced while prompting is never overwritten"
Assert ($context.Results[0].Status -eq 'Failed' -and $context.Results[0].Diagnostic -match 'Refused registry write') 'Changed policy is refused and reported for operator review'
}
foreach ($changed in @(2, 42)) {
Reset-Race
$script:changeAfterJournal = $changed
$context = New-RaceContext
Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing
Assert ($script:writes -eq 0 -and $script:value -eq $changed) "New value $changed introduced after journaling is preserved"
Assert ($context.Results[0].Status -eq 'Failed') 'Prewrite refusal contributes to overall failure'
}
Reset-Race
$script:prewriteReadFails = $true
$context = New-RaceContext
Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Unreadable prewrite policy prevents mutation'
Reset-Race 2
$context = New-RaceContext
Set-WelaNtlmConfigurationControl -Context $context -Scope Outgoing -Mode Audit
Assert ($script:writes -eq 1 -and $script:value -eq 1) 'Explicit Audit still overrides fresh deny intentionally'
Assert ($context.Results[0].Status -eq 'Applied') 'Explicit override requires successful verification'
Write-Host "PASS: $script:assertions integration safety assertions (stub dispatcher and registry; no Windows changes)."
} finally {
foreach ($path in $script:cleanup) {
if (Microsoft.PowerShell.Management\Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force }
}
}
+74
View File
@@ -0,0 +1,74 @@
# Pure policy regressions. No Windows settings are read or changed.
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
$config = Import-WelaAuditProfiles
$assertions = 0
function Assert-Equal($Actual, $Expected, [string]$Message) {
if ($Actual -cne $Expected) { throw "$Message : expected '$Expected', got '$Actual'." }
$script:assertions++
}
$expected = @{
'Group Membership' = @{ Guid = '0CCE9249-69AE-11D9-BED3-505054503030'; Mask = 1; Source = 'ms-sct' }
'Application Group Management' = @{ Guid = '0CCE9239-69AE-11D9-BED3-505054503030'; Mask = 3; Source = 'cis-client' }
'Authorization Policy Change' = @{ Guid = '0CCE9231-69AE-11D9-BED3-505054503030'; Mask = 1; Source = 'cis-client' }
'MPSSVC Rule-Level Policy Change' = @{ Guid = '0CCE9232-69AE-11D9-BED3-505054503030'; Mask = 3; Source = 'ms-wef' }
'IPsec Driver' = @{ Guid = '0CCE9213-69AE-11D9-BED3-505054503030'; Mask = 3; Source = 'ms-audit' }
'Kernel Object' = @{ Guid = '0CCE921F-69AE-11D9-BED3-505054503030'; Mask = 3; Source = 'asd' }
}
$current = @{}
foreach ($policy in $config.catalog) { $current[$policy.guid] = 0 }
foreach ($role in @('Client', 'MemberServer', 'DomainController', 'ADCS')) {
$plan = Get-WelaAuditProfilePlan -Profile 'wela-2.2.0' -Role $role -Build 26100 -Current $current
foreach ($name in $expected.Keys) {
$row = @($plan.policies | Where-Object { $_.id -eq $name })
Assert-Equal $row.Count 1 "$role/$name has exactly one plan row"
Assert-Equal $row[0].guid $expected[$name].Guid "$role/$name canonical GUID"
Assert-Equal $row[0].targetMask $expected[$name].Mask "$role/$name applies the intended mask"
Assert-Equal ($row[0].sourceIds -contains $expected[$name].Source) $true "$role/$name retains provenance"
}
$kernel = $plan.policies | Where-Object { $_.id -eq 'Kernel Object' }
Assert-Equal ($kernel.prerequisites -match 'SACL') $true "$role kernel auditing reports object-SACL dependency"
}
# The WELA extension must not overwrite another guide's semantics.
$inherited = $current.Clone()
$inherited[$expected['Group Membership'].Guid] = 2
$inherited[$expected['Authorization Policy Change'].Guid] = 2
$cis = Get-WelaAuditProfilePlan -Profile 'cis-win11-v4-l1' -Role Client -Build 26100 -Current $inherited
foreach ($name in @('Group Membership', 'Authorization Policy Change')) {
$row = $cis.policies | Where-Object { $_.id -eq $name }
Assert-Equal $row.mode 'minimum' "CIS $name is a minimum"
Assert-Equal $row.targetMask 3 "CIS $name preserves inherited failure auditing"
}
$wef = Get-WelaAuditProfilePlan -Profile 'microsoft-wef-reviewed-2026-09' -Role Client -Build 26100 -Current $current
Assert-Equal (($wef.policies | Where-Object { $_.id -eq 'Authorization Policy Change' }).targetMask) 3 'WEF authorization auditing retains both outcomes'
$server = Get-WelaAuditProfilePlan -Profile 'microsoft-sct-server2025-2602' -Role MemberServer -Build 26100 -Current $current
Assert-Equal (($server.policies | Where-Object { $_.id -eq 'Authorization Policy Change' }).targetMask) 1 'Server 2025 SCT authorization target remains success'
$asd = Get-WelaAuditProfilePlan -Profile 'asd-native-2021-10' -Role Client -Build 26100 -Current $current
Assert-Equal (($asd.policies | Where-Object { $_.id -eq 'Kernel Object' }).targetMask) 3 'ASD kernel target remains both outcomes'
Assert-Equal (($asd.policies | Where-Object { $_.id -eq 'Detailed File Share' }).mode) 'not-configured' 'ASD detailed-share setting is not silently enabled'
# Exercise the actual shared apply path using an in-memory provider.
$script:policyState = $current.Clone()
$script:writes = @{}
$plan = Get-WelaAuditProfilePlan -Profile 'wela-2.2.0' -Role Client -Build 26100 -Current $script:policyState
$result = Invoke-WelaAuditProfilePlan -Plan $plan -ReadPolicy { $script:policyState } -WritePolicy {
param($Guid, $Mask)
$script:policyState[$Guid] = $Mask
$script:writes[$Guid] = $Mask
} -ReadContext { [pscustomobject]@{ Role = 'Client'; Build = 26100 } } -Confirm:$false
Assert-Equal $result.success $true 'Shared apply reports verified success with matching readback'
$kernelResult = $result.results | Where-Object { $_.id -eq 'Kernel Object' }
Assert-Equal ($kernelResult.prerequisites -match 'SACL') $true 'Apply result retains kernel SACL prerequisite'
Assert-Equal ($kernelResult.sourceIds -contains 'asd') $true 'Apply result identifies ASD kernel requirement'
Assert-Equal ($kernelResult.evidence -match 'ASD') $true 'Apply result retains source evidence'
Assert-Equal $result.role 'Client' 'Apply result retains selected role'
Assert-Equal $result.build 26100 'Apply result retains selected build'
foreach ($name in $expected.Keys) {
Assert-Equal $script:writes[$expected[$name].Guid] $expected[$name].Mask "Apply requests correct $name mask"
}
$script:writes = @{}
$result = Invoke-WelaAuditProfilePlan -Plan $plan -ReadPolicy { $script:policyState } -WritePolicy {
param($Guid, $Mask)
$script:writes[$Guid] = $Mask
} -ReadContext { [pscustomobject]@{ Role = 'Client'; Build = 26100 } } -Confirm:$false
Assert-Equal $script:writes.Count 0 'Reapply is idempotent after all controls match'
Write-Host "PASS: $assertions native-audit-control assertions (mocked; no host changes)."
+193
View File
@@ -0,0 +1,193 @@
# Safe unit regressions: load only function definitions, never dispatch WELA or touch Windows policy.
$ErrorActionPreference = 'Stop'
$sourcePath = Join-Path $PSScriptRoot '../WELA.ps1'
$tokens = $null
$parseErrors = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($sourcePath, [ref]$tokens, [ref]$parseErrors)
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
foreach ($functionName in @('Get-WelaOutgoingNtlmPolicySource', 'Get-WelaOutgoingNtlmState', 'Set-WelaOutgoingNtlmPolicy')) {
$definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $functionName }, $true)
if (-not $definition) { throw "Missing function $functionName" }
. ([scriptblock]::Create($definition.Extent.Text))
}
function Assert-Equal($Actual, $Expected, [string]$Message) {
if ($Actual -cne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
$script:assertions++
}
function Assert-Throws([scriptblock]$Action, [string]$Message) {
$threw = $false
try { & $Action } catch { $threw = $true }
Assert-Equal $threw $true $Message
}
function Reset-Policy($Value, [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
$script:onRead = $null
$script:reads = 0
}
function Test-Path { param($LiteralPath, $ErrorAction) return $script:keyExists }
function Get-ItemProperty {
param($LiteralPath, $ErrorAction)
$script:reads++
if ($script:onRead) { & $script:onRead }
if ($script:readFails) { throw 'Access denied' }
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: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 }
$script:assertions = 0
foreach ($initial in @($null, 0, 1)) {
Reset-Policy $initial
Set-WelaOutgoingNtlmPolicy -Auto
Assert-Equal $script:value 1 "Default audits initial value '$initial'"
$expectedWrites = if ($initial -eq 1) { 0 } else { 1 }
Assert-Equal $script:writes $expectedWrites 'Already audited hosts are idempotent'
}
Reset-Policy $null
$script:keyExists = $false
Set-WelaOutgoingNtlmPolicy -Auto
Assert-Equal $script:keyExists $true 'Missing key is created'
Assert-Equal $script:value 1 'Missing key gets audit mode'
Reset-Policy 2
Set-WelaOutgoingNtlmPolicy -Auto
Assert-Equal $script:value 2 'Auto preserves intentional deny'
Assert-Equal $script:writes 0 'Auto does not rewrite deny'
Assert-Equal ((Get-WelaOutgoingNtlmState).Description -like 'Deny all*authentication restriction*') $true 'Deny is reported as enforcement'
Set-WelaOutgoingNtlmPolicy -Mode Audit -Auto
Assert-Equal $script:value 1 'Explicit Audit may replace deny'
Set-WelaOutgoingNtlmPolicy -Mode Deny -Auto
Assert-Equal $script:value 2 'Only explicit Deny opts into enforcement'
Reset-Policy 42
Set-WelaOutgoingNtlmPolicy -Auto
Assert-Equal $script:writes 0 'Unknown value is preserved'
Assert-Equal ((Get-WelaOutgoingNtlmState).Description) 'Unknown registry value (42)' 'Unknown values are reported honestly'
Reset-Policy 0
$script:readFails = $true
Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Unreadable policy fails visibly'
Assert-Equal $script:writes 0 'Unreadable policy is never overwritten'
Reset-Policy 0
Set-WelaOutgoingNtlmPolicy -WhatIf
Assert-Equal $script:writes 0 'WhatIf does not mutate policy'
Assert-Equal $script:prompts 0 'WhatIf does not prompt without Auto'
Reset-Policy 2
$script:response = 'n'
Set-WelaOutgoingNtlmPolicy -Mode Audit
Assert-Equal $script:writes 0 'Declining preserves deny'
$script:response = ''
Set-WelaOutgoingNtlmPolicy -Mode Audit
Assert-Equal $script:value 1 'Confirmed explicit override succeeds'
# A user confirmation must not authorize replacing enforcement that appeared during the prompt.
foreach ($changed in @(2, 42)) {
Reset-Policy 0
$script:changedDuringPrompt = $changed
$script:onPrompt = { $script:value = $script:changedDuringPrompt }
Set-WelaOutgoingNtlmPolicy
Assert-Equal $script:prompts 1 'State changes while the user is confirming'
Assert-Equal $script:value $changed 'Default mode preserves newly applied deny or unknown policy'
Assert-Equal $script:writes 0 'A stale initial read never authorizes replacing new enforcement'
}
Reset-Policy 0
$script:onPrompt = { $script:readFails = $true }
Assert-Throws { Set-WelaOutgoingNtlmPolicy } 'State becoming unreadable during confirmation aborts'
Assert-Equal $script:writes 0 'Unreadable refreshed state is not overwritten'
Reset-Policy 0
$script:onPrompt = { $script:value = 2 }
Set-WelaOutgoingNtlmPolicy -Mode Audit
Assert-Equal $script:value 1 'Explicit Audit still authorizes replacing deny introduced during confirmation'
Assert-Equal $script:writes 1 'Explicit override writes once after a fresh readable state'
Reset-Policy 0
$script:onPrompt = { $script:value = 1 }
Set-WelaOutgoingNtlmPolicy
Assert-Equal $script:writes 0 'A concurrently applied audit setting is not redundantly rewritten'
Reset-Policy 0
$script:onRead = { if ($script:reads -eq 2) { $script:value = 2 } }
Set-WelaOutgoingNtlmPolicy -Auto
Assert-Equal $script:value 2 'Auto also preserves deny introduced after the initial read'
Assert-Equal $script:writes 0 'Auto rechecks immediately before writing'
Reset-Policy 0
$script:writeFails = $true
Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Write failure propagates'
Reset-Policy 0
$script:ignoreWrite = $true
Assert-Throws { Set-WelaOutgoingNtlmPolicy -Auto } 'Read-back mismatch propagates'
Reset-Policy 0
Assert-Equal ((Get-WelaOutgoingNtlmState).PolicySource -like 'Unknown*') $true 'No RSoP does not imply local provenance'
$script:rsop = @(
[pscustomobject]@{ keyName = 'SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'; valueName = 'RestrictSendingNTLMTraffic'; precedence = 2; GPOID = 'Lower priority GPO' },
[pscustomobject]@{ keyName = 'HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'; valueName = 'RestrictSendingNTLMTraffic'; precedence = 1; GPOID = 'Winning GPO' },
[pscustomobject]@{ keyName = 'SYSTEM\Other'; valueName = 'RestrictSendingNTLMTraffic'; precedence = 0; GPOID = 'Unrelated GPO' }
)
$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)."
@@ -0,0 +1,19 @@
# Read-only smoke test of real Windows commands, independent of the mock suite.
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') { throw 'Run this smoke test on Windows.' }
. (Join-Path (Split-Path $PSScriptRoot -Parent) 'scripts/Configuration.ps1')
$mask = Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030'
if ($mask -notin @(0, 1, 2, 3)) { throw "Unexpected process-creation audit mask: $mask" }
# Also exercise the real read-only auditpol command and its native exit status.
$csv = Invoke-WelaNative -FilePath auditpol.exe -Arguments @('/get', '/subcategory:{0CCE922B-69AE-11D9-BED3-505054503030}', '/r')
if ($csv.Diagnostic -notmatch '0CCE922B-69AE-11D9-BED3-505054503030') { throw 'auditpol query returned no requested subcategory.' }
$caught = ''
try { Invoke-WelaNative -FilePath $env:ComSpec -Arguments @('/d', '/c', 'echo WELA-smoke-diagnostic 1>&2 & exit /b 9') }
catch { $caught = $_.ToString() }
if ($caught -notmatch 'exit: 9' -or $caught -notmatch 'WELA-smoke-diagnostic') {
throw "Native exit/stderr capture failed: $caught"
}
# The intentionally failed child was asserted above; do not leak its expected
# exit code into a CI shell wrapper after a successful smoke test.
$global:LASTEXITCODE = 0
Write-Host "Read-only Windows smoke checks passed (process creation audit mask: $mask). No Windows settings changed."
+218
View File
@@ -0,0 +1,218 @@
# No Windows settings are changed. Run with powershell.exe 5.1 or pwsh.
$ErrorActionPreference = 'Stop'
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
# Load trusted source functions into the same scope as the mocks. Windows
# PowerShell 5.1 otherwise resolves a script-local original ahead of global mocks.
$definitions = Get-Content -LiteralPath (Join-Path $repo 'scripts/Configuration.ps1') -Raw
Invoke-Expression ($definitions -replace '(?m)^function ', 'function global:')
$script:passed = 0
function Assert($Condition, [string]$Message) {
if (-not $Condition) { throw "FAIL: $Message" }
$script:passed++
}
function New-TestContext([switch]$DryRun) {
$path = Join-Path ([IO.Path]::GetTempPath()) ('wela-results-test-' + [guid]::NewGuid().ToString('N'))
if (-not $DryRun) { $script:cleanup.Add($path) }
New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath $path
}
$script:cleanup = New-Object 'System.Collections.Generic.List[string]'
try {
foreach ($path in @('WELA.ps1', 'scripts/Configuration.ps1')) {
$parseErrors = $null; $tokens = $null
$null = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo $path), [ref]$tokens, [ref]$parseErrors)
Assert ($parseErrors.Count -eq 0) "Parser accepts $path"
}
# An actual child process exercises exit capture and stderr retention. The
# child only emits text and exits; it never calls Windows configuration tools.
$engine = (Get-Process -Id $PID).Path
$caught = ''
try { Invoke-WelaNative -FilePath $engine -Arguments @('-NoProfile', '-Command', "[Console]::Error.WriteLine('injected native diagnostic'); exit 7") }
catch { $caught = $_.ToString() }
Assert ($caught -match 'exit: 7' -and $caught -match 'injected native diagnostic') 'Native failure retains exit code and stderr'
$ok = Invoke-WelaNative -FilePath $engine -Arguments @('-NoProfile', '-Command', "[Console]::Error.WriteLine('non-fatal diagnostic'); exit 0")
Assert ($ok.ExitCode -eq 0 -and $ok.Diagnostic -match 'non-fatal diagnostic') 'Stderr alone is not a native failure'
$script:state = 1; $script:writes = 0
$read = { $script:state }; $test = { param($value) $value -eq 2 }
$apply = { $script:writes++; $script:state = 2 }
$c = New-TestContext
Invoke-WelaConfigurationControl $c test Registry @{ Path = 'mock'; Name = 'value' } 2 $read $test $apply
Assert ($c.Results[0].Status -eq 'Applied' -and $script:writes -eq 1) 'Changed state is read back before Applied'
$journal = Get-Content -LiteralPath (Join-Path $c.BackupPath 'before.jsonl') | ConvertFrom-Json
Assert ($journal.Before -eq 1 -and $journal.Desired -eq 2) 'Journal contains exact before and requested state'
Invoke-WelaConfigurationControl $c repeated Registry @{} 2 $read $test $apply
Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:writes -eq 1) 'Rerun is idempotent'
$r = Complete-WelaConfiguration $c
Assert ($r.ExitCode -eq 0) 'Verified controls produce successful overall status'
$script:state = 1
$r = Complete-WelaConfiguration $c
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Status -eq 'Overridden') 'Final check detects observed drift without attributing its cause'
$c = New-TestContext -DryRun
$script:writes = 0
Invoke-WelaConfigurationControl $c dry Registry @{} 2 $read $test $apply
Assert ($c.Results[0].Status -eq 'Skipped' -and $script:writes -eq 0) 'Dry run never invokes mutation'
Assert (-not (Test-Path -LiteralPath $c.BackupPath)) 'Dry run creates no backup or journal'
$c = New-TestContext
Invoke-WelaConfigurationControl $c false_success Registry @{} 2 $read $test { }
Assert ($c.Results[0].Status -eq 'Failed') 'Successful write command with wrong read-back is Failed'
Assert ((Complete-WelaConfiguration $c).ExitCode -eq 1) 'Read-back failure makes overall status nonzero'
$c = New-TestContext
$c.BackupPath = Join-Path $c.BackupPath 'missing-parent'
$script:writes = 0
Invoke-WelaConfigurationControl $c journal_failed Registry @{} 2 $read $test $apply
Assert ($c.Results[0].Status -eq 'Failed' -and $script:writes -eq 0) 'Journal failure prevents mutation'
# Registry provider failures and false-success writes use the same verified
# control runner; no actual registry provider is touched in these tests.
$script:registryValue = 0; $script:registryWrites = 0; $script:registryThrows = $true
function global:Get-WelaRegistryState {
param($Path, $Name)
[pscustomobject]@{ KeyExists = $true; ValueExists = $true; Value = $script:registryValue; Type = 'DWord' }
}
function global:Test-Path {
param($LiteralPath, $Path, $ErrorAction)
if ($LiteralPath -like 'HKLM:*') { return $true }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path })
}
function global:Set-ItemProperty {
param($LiteralPath, $Name, $Value, $Type, $ErrorAction)
$script:registryWrites++
if ($script:registryThrows) { throw 'Injected registry access denied' }
$script:registryValue = $Value
}
$c = New-TestContext
Set-WelaRegistryControl $c 'HKLM:\mock' Value 1
Assert ($c.Results[0].Status -eq 'Failed' -and $c.Results[0].Diagnostic -match 'access denied') 'Registry write errors produce failed results'
$script:registryThrows = $false
$c = New-TestContext
Set-WelaRegistryControl $c 'HKLM:\mock' Value 1
Assert ($c.Results[0].Status -eq 'Applied' -and $c.Results[0].After.Value -eq 1) 'Registry writes require verified value and type'
$beforeWrites = $script:registryWrites
Set-WelaRegistryControl $c 'HKLM:\mock' Value 1
Assert ($c.Results[1].Status -eq 'AlreadyCompliant' -and $script:registryWrites -eq $beforeWrites) 'Registry reruns preserve compliant values'
# Missing nested registry parents must be created individually, retaining
# existing parent keys/values. Mock provider rejects children without parents.
$script:mockKeys = @{'HKLM:' = $true; 'HKLM:\SOFTWARE' = $true}
$script:createdKeys = New-Object 'System.Collections.Generic.List[string]'
function global:Test-Path {
param($LiteralPath, $Path, $ErrorAction)
if ($LiteralPath -like 'HKLM:*') { return $script:mockKeys.ContainsKey($LiteralPath) }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path })
}
function global:New-Item {
param($Path, $ItemType, [switch]$Force, $ErrorAction)
if ($Path -notlike 'HKLM:*') { return Microsoft.PowerShell.Management\New-Item @PSBoundParameters }
if ($Force) { throw 'Test refuses Force on registry keys' }
if ($script:mockKeys.ContainsKey($Path)) { throw 'Existing parent would be recreated' }
$parent = $Path.Substring(0, $Path.LastIndexOf('\'))
if (-not $script:mockKeys.ContainsKey($parent)) { throw "Missing registry parent: $parent" }
$script:createdKeys.Add($Path); $script:mockKeys[$Path] = $true
}
New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging'
Assert ($script:createdKeys.Count -eq 5 -and $script:mockKeys.ContainsKey('HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging')) 'Missing registry ancestors are created safely in order'
New-WelaRegistryKey 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ModuleLogging'
Assert ($script:createdKeys.Count -eq 5) 'Existing registry parents are preserved on rerun'
# Function stubs stand in for the Windows APIs from this point onward.
$script:logSize = 1048576; $script:nativeFails = $true; $script:nativeWrites = 0
function global:Get-WinEvent { param($ListLog, $ErrorAction) [pscustomobject]@{ MaximumSizeInBytes = $script:logSize; IsEnabled = $false } }
function global:Invoke-WelaNative {
param($FilePath, $Arguments)
$script:nativeWrites++
if ($script:nativeFails) { throw 'wevtutil.exe failed (exit: 5). Injected access denied' }
$script:logSize = 134217728
[pscustomobject]@{ ExitCode = 0; Output = @(); Diagnostic = 'mock success' }
}
$c = New-TestContext
Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728
$r = Complete-WelaConfiguration $c
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Diagnostic -match 'access denied') 'Injected wevtutil failure survives through the final report'
$script:nativeFails = $false
$c = New-TestContext
Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728
Assert ($c.Results[0].Status -eq 'Applied') 'Event log helper reads verified size'
Set-WelaEventLogControl $c Security MaximumSizeInBytes 134217728
Assert ($c.Results[1].Status -eq 'AlreadyCompliant') 'Event log helper avoids repeated writes'
# Compile the interop declaration without invoking Windows APIs on this host.
Initialize-WelaConfigurationAuditApi
Assert ($null -ne ('Wela.ConfigurationAuditApi' -as [type])) 'Audit query interop compiles'
function global:Get-WelaNativeAuditPolicy { param($Guid) return 3 }
Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 3) 'Audit policy uses native numeric flags independent of locale'
function global:Get-WelaNativeAuditPolicy { param($Guid) return 4 }
Assert ((Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030') -eq 0) 'Native NONE flag normalizes to no success/failure audit'
function global:Get-WelaNativeAuditPolicy { param($Guid) return 16 }
$caught = ''
try { Get-WelaAuditPolicyMask '0CCE922B-69AE-11D9-BED3-505054503030' } catch { $caught = $_.ToString() }
Assert ($caught -ne '') 'Unexpected native flags cannot be marked compliant'
# Extract ConfigureAuditSettings without running the WELA command dispatcher.
$tokens = $null; $errors = $null
$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'), [ref]$tokens, [ref]$errors)
$configure = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'ConfigureAuditSettings' }, $false)
Assert ($configure.Extent.Text -notmatch 'Configuration completed successfully|Start-Process|Out-Null') 'Configure has no unverified native execution or unconditional success'
# Run the actual configure dispatcher in a child process with only the
# configuration function replaced by a harmless failed-report fixture.
$dispatch = $ast.Find({ param($node) $node -is [Management.Automation.Language.SwitchStatementAst] -and $node.Condition.Extent.Text -eq '$Cmd.ToLower()' }, $false)
$clause = @($dispatch.Clauses | Where-Object { $_.Item1.Value -eq 'configure' })[0].Item2.Extent.Text
$child = 'function ConfigureAuditSettings { [pscustomobject]@{ ExitCode = 1; Failed = 1; Results = @() } }; & ' + $clause
$encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($child))
$childOutput = @(& $engine -NoProfile -EncodedCommand $encoded 2>&1)
$childExit = $global:LASTEXITCODE
# GitHub's PowerShell wrapper propagates LASTEXITCODE after the script. This
# child was deliberately failed; assertions below decide the test outcome.
$global:LASTEXITCODE = 0
Assert ($childExit -eq 1) 'The actual configure dispatcher returns nonzero for a failed control report'
# CA-specific wrapper: registry read succeeds, certutil succeeds, restart
# fails. All APIs below are mocks, including Test-Path for the mock CA only.
$realTestPath = (Get-Command Test-Path).Name
function global:Test-Path {
param($LiteralPath, $Path, $ErrorAction)
if ($LiteralPath -like 'HKLM:*') { return $true }
Microsoft.PowerShell.Management\Test-Path -LiteralPath $(if ($LiteralPath) { $LiteralPath } else { $Path })
}
function global:Get-ItemProperty { param($LiteralPath, $Name, $ErrorAction) [pscustomobject]@{ Active = 'MockCA' } }
function global:Join-Path {
param($Path, $ChildPath)
if ($Path -like 'HKLM:*') { return "$Path\$ChildPath" }
Microsoft.PowerShell.Management\Join-Path -Path $Path -ChildPath $ChildPath
}
$script:filter = 0; $script:restartCalls = 0; $script:filterType = 'DWord'
function global:Get-WelaRegistryState { param($Path, $Name) [pscustomobject]@{ ValueExists = $true; Value = $script:filter; Type = $script:filterType; KeyExists = $true } }
function global:Get-Service { param($Name, $ErrorAction) [pscustomobject]@{ Status = 'Running' } }
function global:Restart-Service { param($Name, [switch]$Force, $ErrorAction) $script:restartCalls++; throw 'Injected CertSvc restart failure' }
function global:Invoke-WelaNative { param($FilePath, $Arguments) $script:filter = 127; [pscustomobject]@{ ExitCode = 0; Diagnostic = 'mock certutil' } }
$c = New-TestContext
Set-WelaCertificateAuditControl $c
$r = Complete-WelaConfiguration $c
Assert ($r.ExitCode -eq 1 -and $r.Results[0].Diagnostic -match 'restart failure') 'CA restart failure cannot report success even when registry now equals 127'
$script:filter = 0; $script:restartCalls = 0
function global:Invoke-WelaNative { param($FilePath, $Arguments) throw 'certutil failed (exit: 5)' }
$c = New-TestContext
Set-WelaCertificateAuditControl $c
Assert ($c.Results[0].Status -eq 'Failed' -and $script:restartCalls -eq 0) 'Failed certutil never restarts the CA'
$c = New-TestContext -DryRun
Set-WelaCertificateAuditControl $c
Assert ($c.Results[0].Status -eq 'Skipped' -and $script:restartCalls -eq 0) 'CA dry run never writes or restarts'
$script:filter = '127'; $script:filterType = 'String'
$c = New-TestContext -DryRun
Set-WelaCertificateAuditControl $c
Assert ($c.Results[0].Status -eq 'Skipped') 'REG_SZ 127 is not accepted as a compliant CA DWORD AuditFilter'
Write-Host "$script:passed configuration-result regression assertions passed. No Windows settings changed."
} finally {
foreach ($path in $script:cleanup) {
if (Microsoft.PowerShell.Management\Test-Path -LiteralPath $path) {
Remove-Item -LiteralPath $path -Recurse -Force
}
}
}
@@ -0,0 +1,53 @@
# Actual script-scope helpers and Windows registry provider. Only a unique test
# key under HKCU and a temporary journal are written; no Windows logging changes.
$ErrorActionPreference = 'Stop'
if ($env:OS -ne 'Windows_NT') { throw 'Run this test on Windows.' }
$repo = Split-Path $PSScriptRoot -Parent
$script:ScriptRoot = $repo
. (Join-Path $repo 'scripts/Configuration.ps1')
# Deliberately keep all helper functions script-local, as in WELA.ps1 -File.
# Do not promote helpers or replace their calls with global mocks.
$token = [guid]::NewGuid().ToString('N')
$key = "HKCU:\Software\WELA-Configuration-Scope-$token"
$backup = Join-Path ([IO.Path]::GetTempPath()) "wela-script-scope-$token"
$ownsTestArtifacts = $false
function Assert-Scope($Condition, [string]$Message) {
if (-not $Condition) { throw "FAIL: $Message" }
}
function Add-ScopeControls($Context, [string]$Root) {
Set-WelaRegistryControl -Context $Context -Path "$Root\ParentA\Child" -Name Counter -Value 42
Set-WelaRegistryControl -Context $Context -Path "$Root\ParentB\Child" -Name Label -Value 'second control' -Type String
}
try {
if ((Test-Path -LiteralPath $key) -or (Test-Path -LiteralPath $backup)) { throw 'Unique test artifact unexpectedly exists; refusing to use it.' }
$ownsTestArtifacts = $true
$context = New-WelaConfigurationContext -Auto -BackupPath $backup
# These calls must resolve Get-WelaRegistryState and recursively resolve
# New-WelaRegistryKey from ordinary script scope, then use real provider APIs.
Add-ScopeControls -Context $context -Root $key
$report = Complete-WelaConfiguration -Context $context
Assert-Scope ($report.ExitCode -eq 0) 'Script-local registry helper chain resolves and verifies'
Assert-Scope (@($report.Results | Where-Object Status -eq Applied).Count -eq 2) 'Both distinct control states remain available after their calling function returns'
Assert-Scope ((Get-ItemProperty -LiteralPath "$key\ParentA\Child" -Name Counter).Counter -eq 42) 'Actual DWORD value was written and read back'
Assert-Scope ((Get-ItemProperty -LiteralPath "$key\ParentB\Child" -Name Label).Label -eq 'second control') 'Actual string value was written and read back'
$journal = @(Get-Content -LiteralPath (Join-Path $backup 'before.jsonl') | ConvertFrom-Json)
Assert-Scope ($journal.Count -eq 2 -and -not $journal[0].Before.KeyExists -and -not $journal[1].Before.KeyExists) 'Missing nested registry parents were journaled before creation'
Add-ScopeControls -Context $context -Root $key
$report = Complete-WelaConfiguration -Context $context
Assert-Scope ($report.ExitCode -eq 0 -and @($report.Results | Where-Object Status -eq AlreadyCompliant).Count -eq 2) 'Actual registry rerun is idempotent'
Assert-Scope (@(Get-Content -LiteralPath (Join-Path $backup 'before.jsonl')).Count -eq 2) 'Compliant rerun writes no additional mutation journal records'
# Exercise deferred native-policy and event-log readers in the same script
# scope. DryRun prevents every native configuration or service mutation.
$dry = New-WelaConfigurationContext -Auto -DryRun
Set-WelaRegistryControl -Context $dry -Path "$key\ParentA\Child" -Name Counter -Value 99
Set-WelaAuditPolicyControl -Context $dry -Policy @{ GUID = '0CCE922B-69AE-11D9-BED3-505054503030'; Name = 'Process Creation' }
Set-WelaEventLogControl -Context $dry -Log Security -Property MaximumSizeInBytes -Desired 1
$dryReport = Complete-WelaConfiguration -Context $dry
Assert-Scope ($dryReport.ExitCode -eq 0) 'Deferred native API and event-log callbacks resolve script-local helpers'
Assert-Scope ((Get-ItemProperty -LiteralPath "$key\ParentA\Child" -Name Counter).Counter -eq 42) 'DryRun leaves the actual registry value unchanged'
Write-Host 'Script-scope Windows provider checks passed. Only a disposable HKCU test key and temp journal were changed.'
} finally {
if ($ownsTestArtifacts -and (Test-Path -LiteralPath $key)) { Remove-Item -LiteralPath $key -Recurse -Force -ErrorAction Stop }
if ($ownsTestArtifacts -and (Test-Path -LiteralPath $backup)) { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop }
}
+165
View File
@@ -0,0 +1,165 @@
# Deterministic tests: no elevation, Windows policy writes, or Pester dependency.
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
$script:Checks = 0
function Assert([bool]$Condition, [string]$Message) {
$script:Checks++
if (-not $Condition) { throw "Assertion failed: $Message" }
}
function Assert-Throws([scriptblock]$Action, [string]$Pattern) {
try { & $Action | Out-Null } catch { Assert ($_.Exception.Message -match $Pattern) "Expected '$Pattern', got '$($_.Exception.Message)'"; return }
throw "Expected exception matching '$Pattern'."
}
function Policy($Plan, $Id) { $Plan.policies | Where-Object { $_.id -eq $Id } }
$data = Import-WelaAuditProfiles
Assert ($data.catalog.Count -eq 59) 'all canonical audit subcategories are represented'
$zero = @{}
foreach ($policy in $data.catalog) { $zero[$policy.guid] = 0 }
foreach ($profile in $data.profiles) {
foreach ($range in $profile.appliesTo) {
foreach ($role in $range.roles) {
$plan = Get-WelaAuditProfilePlan -Profile $profile.id -Role $role -Build $range.minBuild -Current $zero
Assert ($plan.policies.Count -eq 59) "$($profile.id)/$role preserves omitted policies explicitly"
Assert ($plan.provenance.Count -gt 0 -and $plan.schemaSha256.Length -eq 64) 'versioned source and schema fingerprints'
}
}
}
$wela = Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role Client -Build 26100 -Current $zero
foreach ($id in @('Process Termination', 'RPC Events', 'Detailed File Share', 'Other Policy Change Events')) {
$row = Policy $wela $id
Assert ($row.mode -eq 'exact' -and $row.targetMask -eq 3) "$id recommendation matches existing configure SF policy"
}
Assert ((Policy $wela 'File System').action -eq 'Optional (not selected)') 'optional controls preserve current state by default'
$opt = Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role Client -Build 26100 -Current $zero -IncludeOptional
Assert ((Policy $opt 'File System').targetMask -eq 3) 'optional control is explicit opt-in'
Assert ((Policy $opt 'File System').prerequisites -match 'SACL') 'SACL dependency is visible'
Assert ((Policy $wela 'Directory Service Access').mode -eq 'not-applicable') 'DC auditing is role scoped'
$adcs = Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role ADCS -Build 20348 -Current $zero
Assert ((Policy $adcs 'Certification Services').targetMask -eq 3) 'CA role is supported'
Assert ((Policy $adcs 'Certification Services').prerequisites -match 'AuditFilter') 'CA prerequisite not silently claimed applied'
$shareGuid = (Policy $wela 'Detailed File Share').guid
$current = $zero.Clone(); $current[$shareGuid] = 1
$cis = Get-WelaAuditProfilePlan -Profile cis-win11-v4-l1 -Role Client -Build 26100 -Current $current
Assert ((Policy $cis 'Detailed File Share').mode -eq 'minimum') 'CIS includes Failure is represented as minimum'
Assert ((Policy $cis 'Detailed File Share').targetMask -eq 3) 'minimum Failure preserves preexisting Success'
$asd = Get-WelaAuditProfilePlan -Profile asd-native-2021-10 -Role Client -Build 26100 -Current $current
Assert ((Policy $asd 'Detailed File Share').mode -eq 'not-configured') 'ASD explicit NC is retained'
Assert ($null -eq (Policy $asd 'Detailed File Share').targetMask) 'NC does not become disabled'
Assert ((Policy $asd 'RPC Events').mode -eq 'unchanged') 'omission is unchanged, not no-auditing'
$unknown = Get-WelaAuditProfilePlan -Profile cis-win11-v4-l1 -Role Client -Build 26100
Assert ($null -eq (Policy $unknown 'Detailed File Share').targetMask -and (Policy $unknown 'Detailed File Share').action -eq 'Unknown') 'unknown current does not become disabled before minimum merge'
Assert-Throws { Get-WelaAuditProfilePlan -Profile microsoft-sct-win11-24h2 -Role Client -Build 26200 } 'does not support'
Assert-Throws { Get-WelaAuditProfilePlan -Profile microsoft-sct-win11-24h2 -Role DomainController -Build 26100 } 'does not support'
Assert-Throws { Get-WelaAuditProfilePlan -Profile typo -Role Client -Build 26100 } 'Unknown audit profile'
# Inject a stateful native boundary, exercising actual selection, merge, verify and failure behavior.
$script:State = $zero.Clone(); $script:Writes = @()
$reader = { return $script:State.Clone() }
$writer = { param($Guid, $Mask) $script:Writes += $Guid; $script:State[$Guid] = $Mask }
$context = { [pscustomobject]@{ Role = 'Client'; Build = 26100 } }
$applied = Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy $writer -ReadContext $context -Confirm:$false
Assert $applied.success 'apply succeeds after verified effective reads'
$processResult = $applied.results | Where-Object { $_.id -eq 'Process Creation' }
Assert ($processResult.prerequisites -match 'Command-line' -and $processResult.sourceIds -contains 'wela') 'apply results retain source and event-generation prerequisites'
Assert ($applied.version -eq $wela.version) 'apply result includes selected source version'
Assert ($script:Writes.Count -gt 0) 'selected exact policies were applied'
Assert (@($applied.results | Where-Object { $_.status -eq 'Applied' -and $_.effectiveMask -ne $_.targetMask }).Count -eq 0) 'applied always means verified'
$count = $script:Writes.Count
$again = Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy $writer -ReadContext $context -Confirm:$false
Assert ($again.success -and $script:Writes.Count -eq $count) 'applying twice is idempotent using fresh current state'
# Apply a stale minimum plan after a preexisting Success flag is introduced: merge fresh state.
$script:State = $zero.Clone(); $script:State[$shareGuid] = 1
$minimum = Invoke-WelaAuditProfilePlan -Plan $cis -ReadPolicy $reader -WritePolicy $writer -ReadContext $context -Confirm:$false
Assert ($minimum.success -and $script:State[$shareGuid] -eq 3) 'fresh effective flags are preserved in minimum apply'
# Minimum readback permits additional flags enabled by Windows/GPO after the write.
$single = Get-WelaAuditProfilePlan -Profile cis-win11-v4-l1 -Role Client -Build 26100 -Current $zero
$single.policies = @($single.policies | Where-Object { $_.id -eq 'Detailed File Share' })
$script:State = $zero.Clone()
$extra = Invoke-WelaAuditProfilePlan -Plan $single -ReadPolicy $reader -WritePolicy {
param($Guid, $Mask, $Mode)
Assert ($Mode -eq 'minimum') 'injected writer receives policy semantics'
$script:State[$Guid] = 3
} -ReadContext $context -Confirm:$false
Assert ($extra.success -and $extra.results[0].targetMask -eq 2 -and $extra.results[0].effectiveMask -eq 3) 'minimum Failure accepts post-write Success+Failure'
# A flag introduced after whole-plan preflight is included in the immediate control read.
$script:State = $zero.Clone(); $script:Reads = 0; $script:WrittenMask = $null
$race = Invoke-WelaAuditProfilePlan -Plan $single -ReadPolicy {
$script:Reads++
if ($script:Reads -eq 2) { $script:State[$shareGuid] = 1 }
$script:State.Clone()
} -WritePolicy {
param($Guid, $Mask, $Mode)
$script:WrittenMask = $Mask
$script:State[$Guid] = $Mask
} -ReadContext $context -Confirm:$false
Assert ($race.success -and $script:WrittenMask -eq 3 -and $race.results[0].beforeMask -eq 1) 'fresh per-control read preserves a flag introduced after preflight'
$script:Reads = 0; $script:WrittenMask = $null
$unknownRace = Invoke-WelaAuditProfilePlan -Plan $single -ReadPolicy {
$script:Reads++
if ($script:Reads -eq 1) { $zero.Clone() } else { @{} }
} -WritePolicy { $script:WrittenMask = 1 } -ReadContext $context -Confirm:$false
Assert (-not $unknownRace.success -and $null -eq $script:WrittenMask -and $unknownRace.results[0].sourceIds.Count -gt 0) 'state becoming unknown blocks that write and retains evidence'
# Verify the real native command contract: minimum never supplies an unrequired disable.
$minimumArgs = @(& (Get-Module AuditProfiles) { param($Guid) Get-WelaAuditSetArguments -Guid $Guid -Mask 2 -Mode minimum } $shareGuid)
Assert ($minimumArgs -contains '/failure:enable' -and @($minimumArgs | Where-Object { $_ -like '/success:*' -or $_ -like '*:disable' }).Count -eq 0) 'minimum Failure writes only failure-enable, preserving concurrent Success'
$exactArgs = @(& (Get-Module AuditProfiles) { param($Guid) Get-WelaAuditSetArguments -Guid $Guid -Mask 1 -Mode exact } $shareGuid)
Assert ($exactArgs -contains '/success:enable' -and $exactArgs -contains '/failure:disable') 'exact Success deliberately clears Failure'
# Role classification must not guess when CA presence is unreadable, or omit CA policy on a DC.
$serverOS = { [pscustomobject]@{ ProductType = 3; BuildNumber = 26100 } }
$dcOS = { [pscustomobject]@{ ProductType = 2; BuildNumber = 26100 } }
$memberSystem = { [pscustomobject]@{ DomainRole = 3 } }
$dcSystem = { [pscustomobject]@{ DomainRole = 5 } }
Assert-Throws { Get-WelaHostContext -ReadOperatingSystem $serverOS -ReadComputerSystem $memberSystem -ReadCertificateAuthority { throw 'CA registry access denied' } } 'access denied'
Assert-Throws { Get-WelaHostContext -ReadOperatingSystem $serverOS -ReadComputerSystem $memberSystem -ReadCertificateAuthority { $null } } 'Cannot determine'
Assert-Throws { Get-WelaHostContext -ReadOperatingSystem $dcOS -ReadComputerSystem $dcSystem -ReadCertificateAuthority { $true } } 'Combined domain-controller/CA'
$ca = Get-WelaHostContext -ReadOperatingSystem $serverOS -ReadComputerSystem $memberSystem -ReadCertificateAuthority { $true }
Assert ($ca.Role -eq 'ADCS') 'member-server CA remains supported'
$dc = Get-WelaHostContext -ReadOperatingSystem $dcOS -ReadComputerSystem $dcSystem -ReadCertificateAuthority { $false }
Assert ($dc.Role -eq 'DomainController') 'DC without CA remains supported'
$script:State = $zero.Clone()
$failed = Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy { throw 'command failed' } -ReadContext $context -Confirm:$false
Assert (-not $failed.success -and @($failed.results | Where-Object { $_.status -eq 'Failed' }).Count -gt 0) 'native failure is machine-readable'
$mismatch = Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy { param($Guid, $Mask) } -ReadContext $context -Confirm:$false
Assert (-not $mismatch.success) 'zero exit without effective change does not count as success'
$failedProcess = $mismatch.results | Where-Object { $_.id -eq 'Process Creation' }
Assert ($failedProcess.status -eq 'Failed' -and $null -eq $failedProcess.effectiveMask -and $failedProcess.prerequisites -match 'Command-line') 'failed/unknown effective state still retains prerequisites'
$script:Writes = @()
$whatIf = Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy $writer -ReadContext $context -WhatIf
Assert ($script:Writes.Count -eq 0) 'WhatIf never invokes native writer'
Assert-Throws { Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy { @{} } -WritePolicy $writer -ReadContext $context -Confirm:$false } 'unknown current'
Assert ($script:Writes.Count -eq 0) 'unknown preflight refuses all writes'
Assert-Throws { Invoke-WelaAuditProfilePlan -Plan $wela -ReadPolicy $reader -WritePolicy $writer -ReadContext { [pscustomobject]@{ Role = 'DomainController'; Build = 26100 } } } 'actual Windows host'
$defaults = Get-WelaAuditProfilePlan -Profile windows-defaults-reviewed-2026-09 -Role Client -Build 26100 -Current $zero
Assert-Throws { Invoke-WelaAuditProfilePlan -Plan $defaults -ReadPolicy $reader -WritePolicy $writer -ReadContext $context } 'reference'
# Schema rejects bad policy names, duplicate GUIDs, invalid masks/modes, and unknown provenance.
$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('wela-profile-test-' + [guid]::NewGuid().ToString() + '.json')
try {
foreach ($case in @('guid', 'mask', 'mode', 'source', 'unknown')) {
$copy = Get-Content (Join-Path $PSScriptRoot '../config/audit_profiles.json') -Raw | ConvertFrom-Json
switch ($case) {
'guid' { $copy.catalog[1].guid = $copy.catalog[0].guid }
'mask' { $copy.profiles[0].controls.'Process Creation'.mask = 7 }
'mode' { $copy.profiles[0].controls.'Process Creation'.mode = 'invented' }
'source' { $copy.profiles[0].sourceIds = @('unreviewed') }
'unknown' { $copy.profiles[0].controls | Add-Member NoteProperty 'Typo Policy' ([pscustomobject]@{ mode = 'exact'; mask = 3 }) }
}
$copy | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $temp -Encoding UTF8
Assert-Throws { Import-WelaAuditProfiles -Path $temp } 'Invalid|Unknown|duplicate'
}
} finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
# Legacy Yamato audit display now takes recommendations from the shared profile, including omitted policies.
. (Join-Path $PSScriptRoot '../WELA.ps1') help -Role Client -Build 26100
function GetAuditpol { return @{} }
$legacy = BuildAuditResult -all_rules @() -Baseline YamatoSecurity -enabledguid @()
foreach ($id in @('Process Termination', 'RPC Events', 'Detailed File Share', 'Other Policy Change Events')) {
$entry = $legacy | Where-Object { $_.SubCategory -eq $id }
Assert ($entry.RecommendedSetting -eq 'Success and Failure [exact]') "legacy audit/settings shares $id recommendation"
}
Assert (@($legacy | Where-Object { $_.Category -like 'Security Advanced*' }).Count -eq 59) 'legacy display includes all canonical GUIDs'
# An unsupported legacy configure target must fail before reaching the old setup body.
function TestWindows { return $true }
function TestAdministrator { return $true }
function Get-WelaHostContext { [pscustomobject]@{ Role = 'Client'; Build = 19045 } }
function Get-WelaEffectiveAuditPolicy { return $zero.Clone() }
function CollectAuditpol { throw 'Reached the old configuration body before profile validation' }
Assert-Throws { ConfigureAuditSettings -Auto } 'does not support'
Write-Host "PASS: $script:Checks audit profile checks; no Windows settings changed."
+27
View File
@@ -0,0 +1,27 @@
# Read-only Windows smoke test: requires administrator or audit-policy query permission.
$ErrorActionPreference = 'Stop'
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
$current = Get-WelaEffectiveAuditPolicy
$catalog = (Import-WelaAuditProfiles).catalog
if ($current.Count -ne $catalog.Count) { throw "Native API returned $($current.Count) policies; expected $($catalog.Count)." }
foreach ($policy in $catalog) {
if (-not $current.ContainsKey($policy.guid) -or $current[$policy.guid] -notin @(0, 1, 2, 3)) {
throw "Missing/invalid effective state: $($policy.id)"
}
}
$context = Get-WelaHostContext
$plan = Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role $context.Role -Build $context.Build -Current $current
Assert-WelaAuditProfileTarget -Plan $plan -Context $context -Current $current
$temp = Join-Path ([System.IO.Path]::GetTempPath()) ('wela-cli-audit-' + [guid]::NewGuid().ToString() + '.json')
try {
# Exercise real script dispatch and export without printing the 59-row table or changing policy.
& (Join-Path $PSScriptRoot '../WELA.ps1') audit -Profile wela-2.2.0 -PlanPath $temp 6>$null | Out-Null
$export = Get-Content -LiteralPath $temp -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
if ($export.policies.Count -ne 59 -or $export.role -ne $context.Role -or $export.build -ne $context.Build -or $export.profile -ne 'wela-2.2.0') {
throw 'CLI audit export did not preserve all policies and the detected role/build.'
}
if (@($export.policies | Where-Object { $null -eq $_.currentMask }).Count -ne 0) {
throw 'CLI audit unexpectedly exported unknown effective policy values.'
}
} finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
Write-Host "PASS: queried all $($current.Count) effective audit policies on $($context.Role) build $($context.Build); CLI audit JSON verified; no settings changed."
@@ -0,0 +1,45 @@
# Native audit controls and their prerequisites
The WELA native profile supports the following audit subcategories in addition to
its original configure policy list. Source-specific profiles keep their own
success/failure masks; selecting a profile does not combine all guides globally.
| Subcategory | WELA target | Other reviewed requirements |
| --- | --- | --- |
| Group Membership | Success | Microsoft SCT, CIS v4 and ASD native: Success; CIS specifies a minimum. |
| Application Group Management | Success and Failure | CIS v4 section 17.2.1: both outcomes. |
| Authorization Policy Change | Success | CIS v4 and Server 2025 SCT: Success; Microsoft WEF Appendix A: both outcomes. |
| MPSSVC Rule-Level Policy Change | Success and Failure | Microsoft SCT, CIS v4 and Microsoft WEF: both outcomes. |
| IPsec Driver | Success and Failure | CIS v4 and Microsoft generic audit guidance: both outcomes. |
| Kernel Object | Success and Failure | ASD native: both outcomes; matching object SACLs and access semantics are separate prerequisites. |
The mask describes policy configuration, not a promise that every operation emits
both kinds of event. Event generation depends on Windows version, role, object
access, and whether the activity occurs. Application Group Management events are
only relevant when application groups are used. Group Membership events provide
logon group context; they do not substitute for Security Group Management events.
IPsec Driver auditing does not enable IPsec or define connection security rules.
Enabling Kernel Object auditing does not create a matching audit ACE on every
object. The plan records this dependency; WELA must not count a rule as verified
solely because the auditpol setting is enabled. The existing `configure-sacl`
command handles selected file/registry targets, not arbitrary kernel objects or
AD directory objects.
Use `plan` to inspect the selected profile and its source provenance before
applying it. The plan distinguishes exact and minimum masks, settings the source
leaves unconfigured, and controls that do not apply to the selected role/build.
Minimum Success or Failure requirements preserve the other effective audit bit.
The advanced-audit profile does not install Sysmon or change firewall enforcement.
## Source versions
- [Microsoft Security Compliance Toolkit](https://www.microsoft.com/en-us/download/details.aspx?id=55319): Windows 11 24H2/25H2 and Windows Server 2022/2025 v2602 packages.
- [Microsoft audit recommendations](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/audit-policy-recommendations).
- [Microsoft WEF Appendix A](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection).
- [ASD Windows event logging and forwarding](https://www.cyber.gov.au/business-government/detecting-responding-to-threats/event-logging/windows-event-logging-and-forwarding): 2021 publication, native fallback.
- CIS Windows 11 Enterprise and Windows Server 2022 **v4.0.0**: historical reviewed benchmarks, not a claim about current CIS requirements. The profile data records control numbers and source links.
Tests exercise policy masks, source-profile differences and the object-auditing
dependency. They do not establish live event production or detection coverage;
validate those on the applicable Windows roles with representative benign events.
+30
View File
@@ -80,3 +80,33 @@ Update WELA's Sigma rules config files:
```
./WELA.ps1 update-rules
```
### Outgoing NTLM auditing and restrictions
`configure` defaults to audit-only outgoing NTLM (`RestrictSendingNTLMTraffic=1`).
An existing `Deny all` value (`2`) is preserved, including with `-Auto`. Unknown
values and unreadable policy are also preserved for review.
```powershell
# Audit outgoing NTLM, preserving an existing restriction.
./WELA.ps1 configure -Auto
# Explicitly replace an existing restriction with audit-only mode.
./WELA.ps1 configure -OutgoingNtlmMode Audit -Auto
# Explicitly opt into denying outgoing NTLM (can break authentication).
./WELA.ps1 configure -OutgoingNtlmMode Deny
```
`-OutgoingNtlmMode PreserveOrAudit` is the default. `Audit` and `Deny` are explicit
operator choices; omitting `-Auto` asks before changing the policy. This option
only affects outgoing NTLM. Incoming and domain auditing remain separate controls.
`audit-settings` includes the current outgoing NTLM value and distinguishes audit
from enforcement in its console and CSV results. Policy provenance is reported as
last-applied RSoP GPO data when available, otherwise **Unknown**. RSoP can be stale,
and neither it nor a registry read proves which component last wrote a value.
After a change WELA verifies the registry value; GPO or MDM can subsequently
reapply another value. Validate benign NTLM events in
`Microsoft-Windows-NTLM/Operational` on an isolated Windows host before deployment.
See [Microsoft's outgoing NTLM policy documentation](https://learn.microsoft.com/en-us/previous-versions/windows/it-pro/windows-10/security/threat-protection/security-policy-settings/network-security-restrict-ntlm-outgoing-ntlm-traffic-to-remote-servers).
The safe mocked regression script is `tests/OutgoingNtlm.Tests.ps1`; its Windows
workflow runs both Windows PowerShell 5.1 and PowerShell 7.
+4
View File
@@ -7,12 +7,16 @@
**改善:**
- `configure`と`configure -Profile`に`-DryRun`と`-ResultsPath`を追加し、Windows設定を変更せずに変更内容を確認し、設定項目ごとの結果をJSONで出力できるようにした。`-DryRun`に対応していないコマンドは、実行前にエラーで停止する。 (#392) (@Shirofune-Security)
- `-BackupPath`と、各設定項目の変更前の状態を記録する復旧用ジャーナルを追加し、手動での復旧手順を文書化した。 (#392) (@Shirofune-Security)
- ベースライン定義を`WELA.ps1`から`config/baselines.json`に外部化し、ベースラインの追加・変更をJSONの編集のみで行えるようにした。 (#358) (@fukusuket)
- `Microsoft-Windows-DFSN-Server/Admin`チャネルを`audit-settings`と`audit-filesize`の確認対象に追加した。 (#358) (@fukusuket)
- MITRE ATT&CK Navigatorのヒートマップを ATT&CK v19 に対応させ、ATT&CK側でrevokedとなった技術IDを置換先に書き換えるようにした(例: v19で`T1685`に統合された`T1562`と`T1562.001`)。Navigatorはrevokedのエントリを黙って破棄するため、従来はその分のカバレッジがヒートマップから欠落していた。 (@fukusuket)
**バグ修正:**
- `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)
- 依存するログが無効になっているルールも使用可能として報告されていた。 (#358) (@fukusuket)
+4
View File
@@ -7,6 +7,8 @@
**Improvements:**
- Added `-DryRun` and `-ResultsPath` to `configure` and `configure -Profile` to preview changes without modifying Windows settings and export per-control results as JSON. Commands that do not support `-DryRun` reject it before running. (#392) (@Shirofune-Security)
- Added `-BackupPath` and a recovery journal that records each control's previous state before making changes, with a documented manual recovery procedure. (#392) (@Shirofune-Security)
- Added a `configure-sacl` command that sets targeted audit SACLs on the autostart/persistence registry keys and sensitive files the detection rules watch, so File System (4663), Registry (4657) and Handle Manipulation (4656) auditing produce useful events without enabling global object auditing. It covers machine-wide objects plus per-user HKCU keys and profile AppData across all user profiles and the Default profile (so future users inherit the SACL). Targets live in `config/audit_sacl_targets.json`. (#361) (@YamatoSecurity)
- `configure` now also enables Detailed Tracking > Process Termination (4689), Object Access > Detailed File Share (5145), and (on domain controllers) LDAP query logging (Directory Service 1644 via NTDS `15 Field Engineering`), so a full detection baseline is applied without any manual `auditpol`/registry steps. (#361) (@YamatoSecurity)
- Baseline definitions were moved out of `WELA.ps1` into a `config/baselines.json` config file, so adding or changing a baseline is now a JSON-only edit. (#358) (@fukusuket)
@@ -15,6 +17,8 @@
**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)
- Rules were reported as usable even when the logs they depend on were disabled. (#358) (@fukusuket)