mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-08 07:15:25 +02:00
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:
32 files changed
+8033
-403
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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,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,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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
# Executes only the exported writer's function definition with safe resolver and
|
||||
# argument-builder fixtures. Native children emit diagnostics and exit; no auditpol
|
||||
# command or Windows policy mutation is ever invoked.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$tokens = $null; $errors = $null
|
||||
$path = Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1'
|
||||
$ast = [Management.Automation.Language.Parser]::ParseFile($path, [ref]$tokens, [ref]$errors)
|
||||
if ($errors.Count) { throw ($errors | Out-String) }
|
||||
$definition = $ast.Find({ param($node) $node -is [Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq 'Set-WelaEffectiveAuditPolicy' }, $true)
|
||||
if (-not $definition) { throw 'Native audit writer definition was not found.' }
|
||||
. ([scriptblock]::Create($definition.Extent.Text))
|
||||
Set-StrictMode -Version 2.0
|
||||
$engine = (Get-Command -Name (Get-Process -Id $PID).Path -CommandType Application -ErrorAction Stop).Source
|
||||
$guid = '0CCE922B-69AE-11D9-BED3-505054503030'
|
||||
$script:assertions = 0; $script:lookups = 0
|
||||
function Assert($Condition, [string]$Message) {
|
||||
if (-not $Condition) { throw "FAIL: $Message" }
|
||||
$script:assertions++
|
||||
}
|
||||
function Invoke-ExpectFailure([scriptblock]$Action, [string]$Pattern) {
|
||||
$caught = ''
|
||||
try { & $Action } catch { $caught = $_.ToString() }
|
||||
Assert ($caught -match $Pattern) "Expected '$Pattern'; observed '$caught'"
|
||||
return $caught
|
||||
}
|
||||
function Get-Command {
|
||||
param($Name, $CommandType, $ErrorAction)
|
||||
$script:lookups++
|
||||
if ($Name -ne 'auditpol.exe' -or $CommandType -ne 'Application' -or $ErrorAction -ne 'Stop') {
|
||||
throw 'Writer must resolve the auditpol application with a terminating lookup.'
|
||||
}
|
||||
if ($script:lookupFails) { throw 'Injected auditpol lookup failure' }
|
||||
[pscustomobject]@{ Source = $script:resolvedSource }
|
||||
}
|
||||
function Get-WelaAuditSetArguments {
|
||||
param($Guid, $Mask, $Mode)
|
||||
return $script:nativeArguments
|
||||
}
|
||||
$script:lookupFails = $true
|
||||
$script:resolvedSource = $engine
|
||||
$script:nativeArguments = @('-NoProfile', '-Command', 'exit 0')
|
||||
$global:LASTEXITCODE = 0
|
||||
$null = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'Injected auditpol lookup failure'
|
||||
Assert ($script:lookups -eq 1) 'A stale native zero cannot bypass a failed executable lookup'
|
||||
|
||||
$script:lookupFails = $false
|
||||
$script:resolvedSource = Join-Path ([IO.Path]::GetTempPath()) ('wela-missing-native-' + [guid]::NewGuid().ToString('N') + '.exe')
|
||||
$global:LASTEXITCODE = 0
|
||||
$null = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'not recognized|failed'
|
||||
Assert ($null -eq $global:LASTEXITCODE) 'An executable disappearing after lookup cannot retain an earlier success code'
|
||||
|
||||
$script:resolvedSource = $engine
|
||||
$script:nativeArguments = @('-NoProfile', '-Command', "[Console]::Error.WriteLine('writer native failure diagnostic'); exit 7")
|
||||
$global:LASTEXITCODE = 0
|
||||
$caught = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'failed \(7\)'
|
||||
Assert ($caught -match 'writer native failure diagnostic') 'Native exit failure retains stderr diagnostics'
|
||||
Assert ($global:LASTEXITCODE -eq 7) 'The newly executed process exit code is observed'
|
||||
|
||||
$script:nativeArguments = @('-NoProfile', '-Command', "[Console]::Error.WriteLine('non-fatal native diagnostic'); exit 0")
|
||||
$global:LASTEXITCODE = 7
|
||||
$PSNativeCommandUseErrorActionPreference = $true
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 3
|
||||
Assert ($global:LASTEXITCODE -eq 0) 'A fresh zero succeeds even with stderr and a previous failure'
|
||||
Assert ($ErrorActionPreference -eq 'Stop' -and $PSNativeCommandUseErrorActionPreference) 'Native preferences remain local to the writer'
|
||||
|
||||
# A malformed/inert executable fixture returns without updating a process exit code.
|
||||
# This proves absence of a new code cannot be mistaken for the previous zero.
|
||||
$script:resolvedSource = { 'Fixture produced no native exit status' }
|
||||
$script:nativeArguments = @()
|
||||
$global:LASTEXITCODE = 0
|
||||
$null = Invoke-ExpectFailure { Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 1 } 'failed \(\)'
|
||||
Assert ($null -eq $global:LASTEXITCODE) 'Missing new native exit status is rejected'
|
||||
|
||||
$script:lookupFails = $true
|
||||
$before = $script:lookups
|
||||
Set-WelaEffectiveAuditPolicy -Guid $guid -Mask 0 -Mode minimum
|
||||
Assert ($script:lookups -eq $before) 'An empty minimum policy does not resolve or execute a writer'
|
||||
$global:LASTEXITCODE = 0 # Expected fixture failures must not fail the CI shell wrapper.
|
||||
Write-Host "PASS: $script:assertions native audit writer assertions (safe native children; no policy changes)."
|
||||
@@ -0,0 +1,113 @@
|
||||
# Exercise the real profile, audit renderer, rule coverage and CSV output with
|
||||
# injected audit observations. Only temporary files are written; no Windows policy changes.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Import-Module (Join-Path $PSScriptRoot '../modules/AuditProfiles.psm1') -Force
|
||||
$tokens = $null; $parseErrors = $null
|
||||
$ast = [System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'), [ref]$tokens, [ref]$parseErrors)
|
||||
if ($parseErrors.Count) { throw ($parseErrors | Out-String) }
|
||||
$class = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.TypeDefinitionAst] -and $node.Name -eq 'WELA' }, $true)
|
||||
. ([scriptblock]::Create($class.Extent.Text))
|
||||
foreach ($name in @('ApplyRules', 'BuildAuditResult', 'AuditLogSetting')) {
|
||||
$definition = $ast.Find({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true)
|
||||
. ([scriptblock]::Create($definition.Extent.Text))
|
||||
}
|
||||
|
||||
$script:assertions = 0
|
||||
function Assert-Equal($Actual, $Expected, [string]$Message) {
|
||||
if ($Actual -cne $Expected) { throw "$Message. Expected '$Expected', got '$Actual'." }
|
||||
$script:assertions++
|
||||
}
|
||||
function TestAdministrator { return $true }
|
||||
function CollectAuditpol { param([switch]$UseCached) return $true }
|
||||
function GetAuditpol { return $script:observedAudit }
|
||||
function Get-WelaSelectedContext { return [pscustomobject]@{ Role = $script:observedRole; Build = 26100 } }
|
||||
function GetBaselineConfig {
|
||||
# Advanced audit policies still come from the actual versioned profile.
|
||||
return [pscustomobject]@{ baselines = [pscustomobject]@{ YamatoSecurity = [pscustomobject]@{} }; catalog = @() }
|
||||
}
|
||||
function Get-WelaOutgoingNtlmState { return [pscustomobject]@{ Description = 'Audit all (1)'; PolicySource = 'Test observation' } }
|
||||
function Get-WelaDomainNtlmState { return [pscustomobject]@{ Description = 'Test observation' } }
|
||||
function Export-MitreHeatmap {
|
||||
param($sigmaRules, $OutputPath, $UseIdealCount)
|
||||
$script:heatmapRules = @($sigmaRules)
|
||||
}
|
||||
|
||||
$catalog = (Import-WelaAuditProfiles).catalog
|
||||
$guids = @{}
|
||||
foreach ($policy in $catalog) { $guids[$policy.id] = $policy.guid }
|
||||
$fallbackGuid = '00000000-0000-0000-0000-000000000001'
|
||||
$script:ScriptRoot = Join-Path ([IO.Path]::GetTempPath()) ('wela-profile-output-' + [guid]::NewGuid().ToString('N'))
|
||||
$null = New-Item -ItemType Directory -Path $script:ScriptRoot
|
||||
$script:SecurityRulesPath = Join-Path $script:ScriptRoot 'rules.json'
|
||||
try {
|
||||
@(
|
||||
@{ id = 'directory'; title = 'DC-only rule'; level = 'high'; subcategory_guids = @($guids['Directory Service Changes']) }
|
||||
@{ id = 'kerberos'; title = 'DC-only rule in a mixed category'; level = 'high'; subcategory_guids = @($guids['Kerberos Authentication Service']) }
|
||||
@{ id = 'credential'; title = 'Disabled rule in a mixed category'; level = 'medium'; subcategory_guids = @($guids['Credential Validation']) }
|
||||
@{ id = 'ca'; title = 'CA-only rule'; level = 'medium'; subcategory_guids = @($guids['Certification Services']) }
|
||||
@{ id = 'kernel'; title = 'Enabled applicable rule'; level = 'medium'; subcategory_guids = @($guids['Kernel Object']) }
|
||||
@{ id = 'alternative'; title = 'Applicable alternative log source'; level = 'medium'; subcategory_guids = @($guids['Directory Service Changes'], $guids['Kernel Object']) }
|
||||
@{ id = 'fallback'; title = 'Enabled policy outside the catalog'; level = 'low'; subcategory_guids = @($fallbackGuid) }
|
||||
@{ id = 'unknown'; title = 'Uncategorized rule'; level = 'low'; subcategory_guids = @() }
|
||||
) | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $script:SecurityRulesPath -Encoding UTF8
|
||||
|
||||
foreach ($role in @('Client', 'MemberServer', 'DomainController', 'ADCS')) {
|
||||
foreach ($observed in @('Success', 'No Auditing', 'Missing')) {
|
||||
$script:observedRole = $role
|
||||
$script:observedAudit = @{}
|
||||
foreach ($policy in $catalog) { $script:observedAudit[$policy.guid] = 'No Auditing' }
|
||||
foreach ($name in @('Directory Service Changes', 'Kerberos Authentication Service', 'Certification Services')) {
|
||||
if ($observed -eq 'Missing') { $script:observedAudit.Remove($guids[$name]) }
|
||||
else { $script:observedAudit[$guids[$name]] = $observed }
|
||||
}
|
||||
$script:observedAudit[$guids['Kernel Object']] = 'Success'
|
||||
$script:observedAudit[$fallbackGuid] = 'Success'
|
||||
|
||||
$output = AuditLogSetting -outType std -Baseline YamatoSecurity 6>&1 | Out-String
|
||||
$rows = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'WELA-Audit-Result.csv'))
|
||||
foreach ($name in @('Directory Service Changes', 'Kerberos Authentication Service', 'Certification Services')) {
|
||||
$policy = $catalog | Where-Object id -eq $name
|
||||
$row = @($rows | Where-Object SubCategory -eq $name)
|
||||
$expectedState = if ($role -notin $policy.roles) { 'Not applicable' }
|
||||
elseif ($observed -eq 'Missing') { 'Unknown' }
|
||||
else { $observed }
|
||||
Assert-Equal $row.Count 1 "$role/$observed contains exactly one $name CSV row"
|
||||
Assert-Equal $row[0].CurrentSetting $expectedState "$role/$observed $name uses role applicability before the live state"
|
||||
$expectedRuleCount = if ($name -eq 'Directory Service Changes') { '2' } else { '1' }
|
||||
Assert-Equal $row[0].RuleCount $expectedRuleCount "$role/$observed retains mapped rules for $name without dropping them from the corpus"
|
||||
}
|
||||
|
||||
if ($role -ne 'DomainController') {
|
||||
Assert-Equal ($output -match '(?m)^Security Advanced \(DS Access\): Not applicable\r?$') $true "$role/$observed all-inapplicable category has no enabled percentage"
|
||||
Assert-Equal ($output -match '(?m)^Security Advanced \(Account Logon\): Disabled\(0[.,]00%\)\r?$') $true "$role/$observed excludes DC-only rows from mixed category totals"
|
||||
}
|
||||
if ($role -ne 'ADCS') {
|
||||
Assert-Equal ($output -match '(?m)^Security Advanced \(Object Access\): Enabled\(100[.,]00%\)\r?$') $true "$role/$observed excludes the CA-only row from enabled category coverage"
|
||||
}
|
||||
|
||||
$usable = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UsableRules.csv'))
|
||||
$unusable = @(Import-Csv -LiteralPath (Join-Path $script:ScriptRoot 'UnusableRules.csv'))
|
||||
$expectedUsable = 3
|
||||
if ($observed -eq 'Success' -and $role -eq 'DomainController') { $expectedUsable += 2 }
|
||||
if ($observed -eq 'Success' -and $role -eq 'ADCS') { $expectedUsable++ }
|
||||
Assert-Equal $usable.Count $expectedUsable "$role/$observed does not rescue role-inapplicable GUIDs as usable"
|
||||
Assert-Equal ($usable.Count + $unusable.Count) 8 "$role/$observed retains all unique rules in the utilization denominator"
|
||||
Assert-Equal ($usable.id -contains 'alternative') $true "$role/$observed permits an applicable alternative source"
|
||||
Assert-Equal ($usable.id -contains 'fallback') $true "$role/$observed still rescues an enabled GUID outside the catalog"
|
||||
Assert-Equal ($usable.id -contains 'unknown') $false "$role/$observed leaves an unknown source unavailable"
|
||||
$expectedUtilization = 'You can utilize {0:N2}% of your detection rules.' -f ($expectedUsable / 8 * 100)
|
||||
Assert-Equal ($output.Contains($expectedUtilization)) $true "$role/$observed reports utilization from the complete deduplicated corpus"
|
||||
foreach ($ruleId in @('directory', 'kerberos', 'ca')) {
|
||||
$rule = $script:heatmapRules | Where-Object id -eq $ruleId
|
||||
$applicableRole = if ($ruleId -eq 'ca') { 'ADCS' } else { 'DomainController' }
|
||||
if ($role -ne $applicableRole) {
|
||||
Assert-Equal $rule.applicable $false "$role/$observed excludes $ruleId from current heatmap coverage"
|
||||
Assert-Equal $rule.ideal $false "$role/$observed excludes $ruleId from ideal heatmap coverage"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Write-Host "PASS: $script:assertions audit profile output assertions (mocked observations; temporary CSV files only)."
|
||||
} finally {
|
||||
Remove-Item -LiteralPath $script:ScriptRoot -Recurse -Force
|
||||
}
|
||||
@@ -18,8 +18,9 @@ function Assert-Throws([scriptblock]$Action, [string]$Message) {
|
||||
try { & $Action } catch { $threw = $true }
|
||||
Assert-Equal $threw $true $Message
|
||||
}
|
||||
function Reset-Policy($Value, $ProductType = 2) {
|
||||
function Reset-Policy($Value, $ProductType = 2, [string]$Type = 'DWord') {
|
||||
$script:value = $Value
|
||||
$script:type = $Type
|
||||
$script:productType = $ProductType
|
||||
$script:writes = 0
|
||||
$script:prompts = 0
|
||||
@@ -27,8 +28,10 @@ function Reset-Policy($Value, $ProductType = 2) {
|
||||
$script:keyExists = $true
|
||||
$script:roleFails = $false
|
||||
$script:readFails = $false
|
||||
$script:typeReadFails = $false
|
||||
$script:writeFails = $false
|
||||
$script:ignoreWrite = $false
|
||||
$script:ignoreTypeWrite = $false
|
||||
$script:response = 'Y'
|
||||
}
|
||||
function Get-CimInstance {
|
||||
@@ -44,13 +47,26 @@ function Get-ItemProperty {
|
||||
if ($null -eq $script:value) { return [pscustomobject]@{} }
|
||||
return [pscustomobject]@{ AuditNTLMInDomain = $script:value }
|
||||
}
|
||||
function Get-Item {
|
||||
param($LiteralPath, $ErrorAction)
|
||||
$key = [pscustomobject]@{}
|
||||
$key | Add-Member ScriptMethod GetValueKind {
|
||||
param($Name)
|
||||
if ($script:typeReadFails) { throw 'Value kind unavailable' }
|
||||
return [Microsoft.Win32.RegistryValueKind]$script:type
|
||||
}
|
||||
return $key
|
||||
}
|
||||
function New-Item { param($Path, [switch]$Force, $ErrorAction) $script:keyExists = $true }
|
||||
function Set-ItemProperty {
|
||||
param($LiteralPath, $Name, $Value, $Type, $ErrorAction)
|
||||
if ($script:writeFails) { throw 'Access denied' }
|
||||
if ($Name -ne 'AuditNTLMInDomain') { throw "Unexpected write: $Name" }
|
||||
$script:writes++
|
||||
if (-not $script:ignoreWrite) { $script:value = $Value }
|
||||
if (-not $script:ignoreWrite) {
|
||||
$script:value = $Value
|
||||
if (-not $script:ignoreTypeWrite) { $script:type = $Type }
|
||||
}
|
||||
}
|
||||
function Read-Host { param($Prompt) $script:prompts++; return $script:response }
|
||||
|
||||
@@ -119,4 +135,23 @@ Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Write failure propagates'
|
||||
Reset-Policy 2
|
||||
$script:ignoreWrite = $true
|
||||
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Read-back mismatch propagates'
|
||||
foreach ($kind in @('String', 'QWord')) {
|
||||
Reset-Policy '7' 2 $kind
|
||||
$state = Get-WelaDomainNtlmState
|
||||
Assert-Equal $state.Type $kind 'Domain state retains registry kind'
|
||||
Assert-Equal ($state.Description -like 'Unknown registry type*expected DWord*') $true 'A non-DWORD 7 is not reported as Enable all'
|
||||
Set-WelaDomainNtlmAudit -Auto
|
||||
Assert-Equal $script:writes 1 'A numerically matching value with the wrong type is repaired'
|
||||
Assert-Equal $script:type 'DWord' 'Domain repair writes DWORD'
|
||||
Assert-Equal ((Get-WelaDomainNtlmState).Description) 'Enable all (7)' 'Only the repaired DWORD is reported as Enable all'
|
||||
}
|
||||
Reset-Policy '7' 2 'String'
|
||||
$script:ignoreTypeWrite = $true
|
||||
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Domain read-back rejects the right value with the wrong type'
|
||||
Assert-Equal $script:writes 1 'Domain read-back type failure occurs after an attempted repair'
|
||||
Reset-Policy 7
|
||||
$script:typeReadFails = $true
|
||||
Assert-Equal ((Get-WelaDomainNtlmState).Readable) $false 'A registry kind read failure is not a readable domain state'
|
||||
Assert-Throws { Set-WelaDomainNtlmAudit -Auto } 'Domain configuration fails closed when registry kind cannot be read'
|
||||
Assert-Equal $script:writes 0 'Unknown domain registry kind is never overwritten'
|
||||
Write-Host "PASS: $script:assertions domain NTLM assertions (mocked; no host changes)."
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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)."
|
||||
@@ -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."
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user