From 80db17891d84c6e43e65cddf74f10e208be8b3c8 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:30:12 +0900
Subject: [PATCH 01/13] Add opt-in WMI namespace audit SACL workflow
---
.github/workflows/wmi-namespace-auditing.yml | 32 ++
CHANGELOG-Japanese.md | 1 +
CHANGELOG.md | 1 +
WELA.ps1 | 29 +-
docs/wmi-namespace-auditing.md | 56 ++++
scripts/Configuration.ps1 | 2 +-
scripts/WmiNamespaceAuditing.ps1 | 312 +++++++++++++++++++
tests/WmiNamespaceAuditing.Tests.ps1 | 137 ++++++++
tests/WmiNamespaceAuditing.Windows.Tests.ps1 | 60 ++++
tests/fixtures/wmi-namespace-descriptor.json | 15 +
website/docs/resources/changelog.ja.md | 1 +
website/docs/resources/changelog.md | 1 +
12 files changed, 644 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/wmi-namespace-auditing.yml
create mode 100644 docs/wmi-namespace-auditing.md
create mode 100644 scripts/WmiNamespaceAuditing.ps1
create mode 100644 tests/WmiNamespaceAuditing.Tests.ps1
create mode 100644 tests/WmiNamespaceAuditing.Windows.Tests.ps1
create mode 100644 tests/fixtures/wmi-namespace-descriptor.json
diff --git a/.github/workflows/wmi-namespace-auditing.yml b/.github/workflows/wmi-namespace-auditing.yml
new file mode 100644
index 00000000..3e991ecd
--- /dev/null
+++ b/.github/workflows/wmi-namespace-auditing.yml
@@ -0,0 +1,32 @@
+name: WMI namespace auditing regressions
+on:
+ push:
+ branches: ['**']
+ paths:
+ - 'WELA.ps1'
+ - 'scripts/Configuration.ps1'
+ - 'scripts/WmiNamespaceAuditing.ps1'
+ - 'tests/WmiNamespaceAuditing*'
+ - 'tests/fixtures/wmi-namespace-descriptor.json'
+ - '.github/workflows/wmi-namespace-auditing.yml'
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ wmi-namespace-auditing:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Mocked namespace SACL regression tests (Windows PowerShell 5.1)
+ shell: powershell
+ run: ./tests/WmiNamespaceAuditing.Tests.ps1
+ - name: Native read-only and in-memory writer adapter (Windows PowerShell 5.1)
+ shell: powershell
+ run: ./tests/WmiNamespaceAuditing.Windows.Tests.ps1
+ - name: Mocked namespace SACL regression tests (PowerShell 7)
+ shell: pwsh
+ run: ./tests/WmiNamespaceAuditing.Tests.ps1
+ - name: Native read-only and in-memory writer adapter (PowerShell 7)
+ shell: pwsh
+ run: ./tests/WmiNamespaceAuditing.Windows.Tests.ps1
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index c79f4a86..bc0e29e0 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,7 @@
**改善:**
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#372) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab71489f..e45fed6e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
**Improvements:**
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#372) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index c8196e0a..18e9f807 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -22,6 +22,9 @@
[ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384,
[string]$HtmlPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit',
+ [ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$WmiAction = 'List',
+ [string[]]$WmiNamespace,
+ [switch]$WmiIncludeChildren,
[switch]$Help
)
@@ -38,6 +41,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/Configuration.ps1")
. (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1")
. (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1")
+. (Join-Path $ScriptRoot "scripts/WmiNamespaceAuditing.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
@@ -1666,6 +1670,10 @@ function Get-WelaUserProfiles {
$usage = @"
Usage:
+ ./WELA.ps1 wmi-auditing -WmiAction List
+ ./WELA.ps1 wmi-auditing -WmiAction Plan -WmiNamespace root\cimv2 -ResultsPath wmi-plan.json
+ ./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace root\cimv2 -DryRun
+ # Namespace SACLs are opt-in; descendants require -WmiIncludeChildren. See docs/wmi-namespace-auditing.md.
./WELA.ps1 firewall-logging -FirewallAction Audit -ResultsPath firewall.json
./WELA.ps1 firewall-logging -FirewallAction Plan -FirewallPathMode CisV4
./WELA.ps1 firewall-logging -FirewallAction Configure -DryRun
@@ -1705,8 +1713,12 @@ Write-Host ""
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($DryRun -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
- -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure')) {
- throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure and smb-auditing -SmbAction Configure. No command was run."
+ -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
+ -not ($Cmd -eq 'wmi-auditing' -and $WmiAction -eq 'Configure')) {
+ throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure smb-auditing -SmbAction Configure and wmi-auditing -WmiAction Configure. No command was run."
+}
+if (($WmiNamespace -or $WmiIncludeChildren -or $PSBoundParameters.ContainsKey('WmiAction')) -and $Cmd -ne 'wmi-auditing') {
+ throw '-WmiAction, -WmiNamespace and -WmiIncludeChildren require wmi-auditing. No command was run.'
}
if ($Profile -and $Cmd -in @('eventlog-profiles', 'audit-filesize', 'configure-eventlogs')) {
throw '-Profile selects advanced audit policy only. Use -LogProfile for event-log size/mode settings.'
@@ -1724,6 +1736,19 @@ if ($Profile -and $Cmd.ToLower() -in @('plan', 'audit', 'audit-settings', 'confi
}
switch ($Cmd.ToLower()) {
+ 'wmi-auditing' {
+ if ($Help) {
+ Write-Host 'Usage: ./WELA.ps1 wmi-auditing -WmiAction List|Audit|Plan|Configure [-WmiNamespace root\cimv2,root\subscription] [-WmiIncludeChildren] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+ Write-Host 'Select exact local namespaces explicitly. Default action List is read-only. Configure appends ASD success audit ACEs; descendant inheritance requires an explicit switch. No access permissions, audit policy or forwarding changes.'
+ return
+ }
+ if ($Profile -or $Baseline) { throw 'wmi-auditing uses its own namespace selections, not -Profile or -Baseline.' }
+ try {
+ $report = Invoke-WelaWmiAuditCommand -Action $WmiAction -Namespace $WmiNamespace -IncludeChildren:$WmiIncludeChildren -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
+ $report
+ if ($report.ExitCode) { exit $report.ExitCode }
+ } catch { Write-Host "[Failed] WMI namespace auditing: $_" -ForegroundColor Red; exit 1 }
+ }
'firewall-logging' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 firewall-logging [-FirewallAction Audit|Plan|Configure] [-FirewallPathMode Preserve|CisV4] [-FirewallMinimumSizeKiB 16384..32767] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
diff --git a/docs/wmi-namespace-auditing.md b/docs/wmi-namespace-auditing.md
new file mode 100644
index 00000000..c550a944
--- /dev/null
+++ b/docs/wmi-namespace-auditing.md
@@ -0,0 +1,56 @@
+# Optional WMI namespace auditing
+
+`wmi-auditing` appends reviewed success-audit entries to explicitly selected **local** WMI namespace SACLs. It does not run during ordinary `configure`, change namespace access permissions, create namespaces, enable remote WMI access, change audit policy, install a forwarding subscription, or grant rule-coverage credit. PowerShell 5.1 and PowerShell 7 on Windows use the same `System.Management` provider methods.
+
+```powershell
+./WELA.ps1 wmi-auditing -WmiAction List
+./WELA.ps1 wmi-auditing -WmiAction Audit -WmiNamespace 'root\cimv2' -ResultsPath wmi-before.json
+./WELA.ps1 wmi-auditing -WmiAction Plan -WmiNamespace 'root\cimv2','root\subscription' -ResultsPath wmi-plan.json
+./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace 'root\cimv2' -DryRun
+./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace 'root\cimv2' -BackupPath C:\WelaBackups\wmi-change-001 -ResultsPath wmi-result.json
+# Explicitly opt in to the reference script's descendant inheritance:
+./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace 'root\subscription' -WmiIncludeChildren
+```
+
+The default action is read-only `List`. Audit/Plan/Configure require exact namespace selections; wildcards, remote paths, unreviewed namespaces and empty selections are rejected. `-Auto` skips per-namespace confirmation after the operator has selected the scope. `-DryRun` is supported only with Configure, and calls no setter or journal writer. `-Profile` and `-Baseline` do not select WMI SACLs.
+
+## Reference entries and scope
+
+The entries come from the [ASD WMI script pinned at 59041b5](https://github.com/AustralianCyberSecurityCentre/windows_event_logging/blob/59041b5d4586789a751171fb752be1624ad5e3b4/events/wmi_auditing/wmi_auditing.ps1). Every new ACE has type 2 (system audit), success only. Existing failure entries and unfamiliar ACEs are retained.
+
+| Namespace | Principal | Mask | Audited namespace rights | ASD flags |
+| --- | --- | --- | --- | --- |
+| `root\cimv2` | Everyone `S-1-1-0` | `0x40002` (262146) | Execute Methods, Edit Security | 64 |
+| `root\cimv2` | Interactive `S-1-5-4` | `0x1` | Enable Account / read | 64 |
+| `root\cimv2` | Network `S-1-5-2` | `0x1` | Enable Account / read | 64 |
+| `root\cimv2` | Batch `S-1-5-3` | `0x1` | Enable Account / read | 64 |
+| `root\SecurityCenter` | Everyone | `0x40001` (262145) | Enable Account / read, Edit Security | 66 |
+| `root\SecurityCenter2` | Everyone | `0x40001` (262145) | Enable Account / read, Edit Security | 66 |
+| `root\subscription` | Everyone | `0x4001E` (262174) | Execute Methods, Full Write, Partial Write, Provider Write, Edit Security | 66 |
+| `root\default` | Everyone | `0x4001F` (262175) | Read plus all preceding rights | 66 |
+
+The numeric subscription mask includes Execute Methods even though the reference script's comment omits it. WELA uses the actual numeric mask. Flags 64 mean success on this namespace; 66 add container inheritance. **By default WELA uses 64 for every selection**, limiting new entries to that namespace. `-WmiIncludeChildren` enables the reference's flag 66 for the four applicable namespaces. This may propagate audit ACEs to inheriting descendants, including existing and future child namespaces; it does not grant access. Child ACL propagation is not enumerated, backed up or verified by this command, and is an explicit additional scope requiring a lab review. Existing inherited entries are retained in either mode. SecurityCenter namespaces are commonly absent on servers; absence is reported rather than treated as successful configuration.
+
+## Privileges, preservation and results
+
+Run elevated with **SeSecurityPrivilege assigned** for Audit/Plan/Configure. WELA enables this privilege in its process while accessing the descriptor, restores the previous token state afterward, and requests privileges for the local WMI connection. Without it a provider can return a DACL while omitting the SACL; WELA refuses that ambiguous read. List only enumerates the supported root child namespaces and reports Present, NotInstalled or Unknown.
+
+Each GetSecurityDescriptor and SetSecurityDescriptor return code must be explicitly zero. Exceptions, denied/missing namespaces, incomplete descriptors, nonzero return codes, ineffective writes and failed read-back are failures. The journal stores the complete provider descriptor as JSON and MOF strings before the setter is called; nested entries cannot be truncated by the outer result serializer. Native objects are cloned rather than rebuilt from a shortened permission list. Existing ACEs, duplicate/unknown ACEs, DACL order, owner, group and other descriptor fields are preserved. The only control flag added is `SE_SACL_PRESENT` when needed. Provider representations that cannot round-trip unchanged fail verification; unknown entries are never deliberately simplified or discarded.
+
+Immediately before writing, WELA reads the full descriptor again and refuses to overwrite a changed snapshot. Read-back checks all original fields/ACE multiplicities and every requested exact audit entry. The final check detects descriptor drift after verification. This is not an atomic transaction with other administrators or management software: changes between the last read and the provider write remain possible. No automatic rollback overwrites concurrent changes.
+
+An exact existing entry is not duplicated. Different masks, audit outcomes, inheritance, object-specific ACEs or inherited ACEs are preserved and do not suppress the explicit requested entry. Result statuses use the shared configuration contract: Applied/AlreadyCompliant indicate observed SACL compliance, Skipped includes dry-run or declined changes, and Failed/Overridden produce exit code 1. Exit code 0 alone is not evidence of a write or successful event generation.
+
+## Audit prerequisites and local/remote evidence
+
+WELA separately observes the effective **Other Object Access Events** audit policy (success bit) without changing it. A missing/unknown prerequisite is visible in `Prerequisite`; a successful SACL update alone does not establish event readiness. Review audit precedence and the effective policy using the separate audit-policy workflow.
+
+[Microsoft documents namespace auditing](https://learn.microsoft.com/en-us/windows/win32/wmisdk/access-to-wmi-namespaces) as Security event **4662** for matching namespace access checks. It does not establish whether the subsequent provider operation succeeded. Interactive/Network/Batch SIDs select token membership, not a universal local/remote classification: validate the logon type, user SID, namespace and access mask in observed XML. Remote Enable (`0x20`) is not added to the DACL or the new audit mask. WMI-Activity/Operational telemetry is a separate evidence source and is not made equivalent to namespace Security events.
+
+## Recovery and remaining lab verification
+
+Keep the new backup directory and result JSON outside temporary folders. `before.jsonl` contains each selected namespace's original `DescriptorJson` and `DescriptorMof`, namespace name and proposed entries. Compare these with a fresh Audit export before making any recovery change. In an elevated WMI Control (`wmimgmt.msc`), select the exact namespace, Security > Advanced > Auditing, and remove only entries that this run added after confirming they were absent from the original descriptor. Restore changed audit flags/masks from the original export if necessary; retain unrelated owner/group/DACL and newer administrative changes. WELA deliberately provides no blind whole-descriptor restore. An existing matching ACE was not created by this run and must not be removed. If descendant inheritance was enabled, inspect affected child namespaces independently and use a pre-change machine snapshot if a complete rollback is needed.
+
+CI uses synthetic descriptors, a real privileged read of root\cimv2, an in-memory native writer adapter and a read-only dry-run on Windows PowerShell 5.1/7. It never sends SetSecurityDescriptor to a live namespace. **Live SACL writes, benign local/remote event generation, child propagation and forwarding have not been verified by these tests.** Before deployment, use isolated patched snapshots of Windows 11, member server, domain controller and AD CS hosts; record descriptors/effective audit policy before and after, repeat configuration for idempotence, issue benign local and remote calls with known tokens, capture Security 4662 XML, and test the chosen WEF subscription and collector receipt. Validate namespace `ObjectName` and access masks, not EventID alone. These are pending acceptance labs, not claimed Sigma uplift.
+
+Additional primary references: [SetSecurityDescriptor and preservation flags](https://learn.microsoft.com/en-us/windows/win32/wmisdk/setsecuritydescriptor-method-in-class---systemsecurity), [namespace access masks](https://learn.microsoft.com/en-us/windows/win32/wmisdk/namespace-access-rights-constants), [namespace inheritance flags](https://learn.microsoft.com/en-us/windows/win32/wmisdk/namespace-ace-flag-constants).
diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1
index b0fe1121..ab3c028c 100644
--- a/scripts/Configuration.ps1
+++ b/scripts/Configuration.ps1
@@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl {
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
- [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only")]
+ [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "wmi-namespace-sacl-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
# A second read detects a value that was compliant earlier but changed during
diff --git a/scripts/WmiNamespaceAuditing.ps1 b/scripts/WmiNamespaceAuditing.ps1
new file mode 100644
index 00000000..c3013b55
--- /dev/null
+++ b/scripts/WmiNamespaceAuditing.ps1
@@ -0,0 +1,312 @@
+# Opt-in local namespace SACLs. No namespace DACL, audit policy, or remote-access changes.
+function Get-WelaWmiAuditDefinitions {
+ param([string[]]$Namespace, [switch]$IncludeChildren)
+ $source = 'https://github.com/AustralianCyberSecurityCentre/windows_event_logging/blob/59041b5d4586789a751171fb752be1624ad5e3b4/events/wmi_auditing/wmi_auditing.ps1'
+ $rows = @(
+ @('root\cimv2', 262146, 64, 'S-1-1-0'),
+ @('root\cimv2', 1, 64, 'S-1-5-4'),
+ @('root\cimv2', 1, 64, 'S-1-5-2'),
+ @('root\cimv2', 1, 64, 'S-1-5-3'),
+ @('root\SecurityCenter', 262145, 66, 'S-1-1-0'),
+ @('root\SecurityCenter2', 262145, 66, 'S-1-1-0'),
+ @('root\subscription', 262174, 66, 'S-1-1-0'),
+ @('root\default', 262175, 66, 'S-1-1-0')
+ )
+ foreach ($selected in $Namespace) {
+ if ($selected -notin @($rows | ForEach-Object { $_[0] })) { throw "Unsupported namespace '$selected'. Select exact local namespaces listed by wmi-auditing -WmiAction List; wildcards and remote paths are not accepted." }
+ }
+ foreach ($row in $rows) {
+ if ($Namespace -and $row[0] -notin $Namespace) { continue }
+ [pscustomobject][ordered]@{ Namespace = $row[0]; AccessMask = [uint32]$row[1]; AceType = 2
+ AceFlags = $(if ($IncludeChildren) { [uint32]$row[2] } else { [uint32]64 }); Sid = $row[3]
+ SourceAceFlags = $row[2]; Source = $source; AuditOutcome = 'Success'
+ Scope = $(if ($IncludeChildren -and $row[2] -eq 66) { 'Selected namespace and inheriting descendants' } else { 'Selected namespace only' }) }
+ }
+}
+
+function Initialize-WelaWmiInterop {
+ if ($env:OS -ne 'Windows_NT') { throw 'WMI namespace security requires Windows.' }
+ Add-Type -AssemblyName System.Management -ErrorAction Stop
+ if ('Wela.WmiSecurityPrivilege' -as [type]) { return }
+ Add-Type -TypeDefinition @'
+using System;
+using System.ComponentModel;
+using System.Runtime.InteropServices;
+namespace Wela {
+ public sealed class WmiSecurityPrivilege : IDisposable {
+ [StructLayout(LayoutKind.Sequential)] struct Luid { public uint Low; public int High; }
+ [StructLayout(LayoutKind.Sequential)] struct TokenPrivileges { public uint Count; public Luid Luid; public uint Attributes; }
+ [DllImport("kernel32.dll")] static extern IntPtr GetCurrentProcess();
+ [DllImport("kernel32.dll", SetLastError=true)] static extern bool CloseHandle(IntPtr handle);
+ [DllImport("advapi32.dll", SetLastError=true)] static extern bool OpenProcessToken(IntPtr process, uint access, out IntPtr token);
+ [DllImport("advapi32.dll", CharSet=CharSet.Unicode, SetLastError=true)] static extern bool LookupPrivilegeValue(string system, string name, out Luid luid);
+ [DllImport("advapi32.dll", SetLastError=true)] static extern bool AdjustTokenPrivileges(IntPtr token, bool disable, ref TokenPrivileges current, uint size, out TokenPrivileges previous, out uint required);
+ IntPtr token; TokenPrivileges previous; bool changed;
+ public WmiSecurityPrivilege() {
+ if (!OpenProcessToken(GetCurrentProcess(), 0x28, out token)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ try {
+ Luid luid;
+ if (!LookupPrivilegeValue(null, "SeSecurityPrivilege", out luid)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ TokenPrivileges requested = new TokenPrivileges { Count=1, Luid=luid, Attributes=2 };
+ uint required;
+ bool ok = AdjustTokenPrivileges(token, false, ref requested, (uint)Marshal.SizeOf(typeof(TokenPrivileges)), out previous, out required);
+ int error = Marshal.GetLastWin32Error();
+ if (!ok || error != 0) throw new Win32Exception(error, "SeSecurityPrivilege must be assigned and enabled; refusing a potentially incomplete SACL read.");
+ changed=true;
+ } catch { CloseHandle(token); token=IntPtr.Zero; throw; }
+ }
+ public void Dispose() {
+ if (token==IntPtr.Zero) return;
+ try {
+ if (changed) {
+ TokenPrivileges ignored; uint required;
+ if (!AdjustTokenPrivileges(token, false, ref previous, (uint)Marshal.SizeOf(typeof(TokenPrivileges)), out ignored, out required)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ }
+ } finally { CloseHandle(token); token=IntPtr.Zero; }
+ }
+ }
+}
+'@ -ErrorAction Stop
+}
+
+function Assert-WelaWmiReturnCode {
+ param($Response, [string]$Method)
+ if ($null -eq $Response -or $null -eq $Response.ReturnValue -or
+ $Response.ReturnValue -is [bool] -or [string]$Response.ReturnValue -notmatch '^\d+$' -or
+ [uint64]$Response.ReturnValue -ne 0) {
+ throw "$Method failed (ReturnValue=$($Response.ReturnValue)); success requires an explicit numeric zero."
+ }
+}
+
+function ConvertTo-WelaWmiData {
+ param($Value)
+ if ($null -eq $Value) { return $null }
+ if ($Value -is [System.Management.ManagementBaseObject]) {
+ $properties = [ordered]@{}
+ foreach ($property in @($Value.Properties | Sort-Object Name)) { $properties[$property.Name] = ConvertTo-WelaWmiData $property.Value }
+ return [pscustomobject]$properties
+ }
+ if ($Value -is [array]) {
+ $items = @(); foreach ($item in $Value) { $items += ,(ConvertTo-WelaWmiData $item) }
+ return ,$items
+ }
+ return $Value
+}
+
+function ConvertTo-WelaWmiJson { param($Value) ConvertTo-Json -InputObject $Value -Depth 40 -Compress }
+
+function Get-WelaWmiSid {
+ param($Trustee)
+ if ($Trustee.SIDString) { return [string]$Trustee.SIDString }
+ $bytes = [byte[]]$Trustee.SID
+ if (-not $bytes -or $bytes.Length -lt 8 -or $bytes.Length -ne (8 + 4 * $bytes[1])) { return '' }
+ [uint64]$authority = 0
+ for ($i = 2; $i -lt 8; $i++) { $authority = ($authority * 256) + $bytes[$i] }
+ $sid = "S-$($bytes[0])-$authority"
+ for ($i = 0; $i -lt $bytes[1]; $i++) { $sid += '-' + [BitConverter]::ToUInt32($bytes, 8 + 4 * $i) }
+ return $sid
+}
+
+function Test-WelaWmiAceMatch {
+ param($Ace, $Definition)
+ # Only an exact, explicit, ordinary success ACE satisfies a requested entry.
+ # Unknown/object/inherited ACEs are retained without interpreting them.
+ return $null -ne $Ace -and $Ace.AceType -eq 2 -and $Ace.AceFlags -eq $Definition.AceFlags -and
+ $Ace.AccessMask -eq $Definition.AccessMask -and -not $Ace.GuidObjectType -and -not $Ace.GuidInheritedObjectType -and
+ (Get-WelaWmiSid $Ace.Trustee) -eq $Definition.Sid
+}
+
+function Get-WelaWmiMissingAces {
+ param($Descriptor, [array]$Definitions)
+ foreach ($definition in $Definitions) {
+ $matches = @($Descriptor.SACL | Where-Object { Test-WelaWmiAceMatch $_ $definition })
+ if ($matches.Count -eq 0) { $definition }
+ }
+}
+
+function New-WelaWmiConnection {
+ param([string]$Namespace)
+ $options = New-Object System.Management.ConnectionOptions
+ $options.EnablePrivileges = $true
+ $options.Impersonation = [System.Management.ImpersonationLevel]::Impersonate
+ $scope = New-Object System.Management.ManagementScope -ArgumentList "\\.\$Namespace", $options
+ $scope.Connect()
+ $path = New-Object System.Management.ManagementPath -ArgumentList '__SystemSecurity=@'
+ return New-Object System.Management.ManagementObject -ArgumentList $scope, $path, $null
+}
+
+function Get-WelaWmiNativeDescriptor {
+ param($Connection)
+ $result = $Connection.InvokeMethod('GetSecurityDescriptor', $null, $null)
+ Assert-WelaWmiReturnCode $result 'GetSecurityDescriptor'
+ if ($null -eq $result.Descriptor -or $null -eq $result.Descriptor.ControlFlags) { throw 'GetSecurityDescriptor returned no complete descriptor.' }
+ return $result.Descriptor
+}
+
+function Get-WelaWmiNamespaceSnapshot {
+ param([string]$Namespace)
+ Initialize-WelaWmiInterop
+ $privilege = New-Object Wela.WmiSecurityPrivilege
+ $connection = $null
+ try {
+ $connection = New-WelaWmiConnection $Namespace
+ $descriptor = Get-WelaWmiNativeDescriptor $connection
+ $data = ConvertTo-WelaWmiData $descriptor
+ # Strings prevent JSON journal depth truncation of nested, unfamiliar ACEs.
+ [pscustomobject]@{ Namespace = $Namespace; DescriptorJson = ConvertTo-WelaWmiJson $data
+ DescriptorMof = $descriptor.GetText([System.Management.TextFormat]::Mof); SaclReadPrivilege = 'SeSecurityPrivilege enabled' }
+ } finally { if ($connection) { $connection.Dispose() }; $privilege.Dispose() }
+}
+
+function Set-WelaWmiNamespaceDescriptor {
+ param([string]$Namespace, [string]$ExpectedJson, [array]$Definitions)
+ Initialize-WelaWmiInterop
+ $privilege = New-Object Wela.WmiSecurityPrivilege
+ $connection = $null
+ try {
+ $connection = New-WelaWmiConnection $Namespace
+ $descriptor = Get-WelaWmiNativeDescriptor $connection
+ $data = ConvertTo-WelaWmiData $descriptor
+ if ((ConvertTo-WelaWmiJson $data) -cne $ExpectedJson) { throw 'Namespace descriptor changed after its recovery snapshot; no SACL was written. Review and retry.' }
+ $missing = @(Get-WelaWmiMissingAces $data $Definitions)
+ if (-not $missing.Count) { return 'Requested audit ACEs already present at the immediate pre-write read.' }
+ # Clone the full native descriptor; existing native ACE objects are not
+ # reconstructed from selected fields, merged, reordered, or removed.
+ $updated = $descriptor.Clone()
+ $aces = @($descriptor.SACL | Where-Object { $null -ne $_ })
+ foreach ($definition in $missing) {
+ $aceClass = New-Object System.Management.ManagementClass -ArgumentList '\\.\root\cimv2:Win32_ACE'
+ $trusteeClass = New-Object System.Management.ManagementClass -ArgumentList '\\.\root\cimv2:Win32_Trustee'
+ try {
+ $ace = $aceClass.CreateInstance(); $trustee = $trusteeClass.CreateInstance()
+ $sid = New-Object System.Security.Principal.SecurityIdentifier -ArgumentList $definition.Sid
+ $sidBytes = New-Object byte[] $sid.BinaryLength; $sid.GetBinaryForm($sidBytes, 0)
+ $trustee.SID = $sidBytes
+ $ace.Trustee = $trustee; $ace.AccessMask = [uint32]$definition.AccessMask
+ $ace.AceFlags = [uint32]$definition.AceFlags; $ace.AceType = [uint32]2
+ $aces += $ace
+ } finally { $aceClass.Dispose(); $trusteeClass.Dispose() }
+ }
+ $updated.SACL = [System.Management.ManagementBaseObject[]]$aces
+ # Only SE_SACL_PRESENT is added when absent. Every other control bit stays.
+ $updated.ControlFlags = [uint32]$descriptor.ControlFlags -bor [uint32]16
+ $parameters = $connection.GetMethodParameters('SetSecurityDescriptor')
+ $parameters.Descriptor = $updated
+ $response = $connection.InvokeMethod('SetSecurityDescriptor', $parameters, $null)
+ Assert-WelaWmiReturnCode $response 'SetSecurityDescriptor'
+ 'SACL update accepted; full descriptor preservation and audit entries require read-back verification. Event generation is unverified.'
+ } finally { if ($connection) { $connection.Dispose() }; $privilege.Dispose() }
+}
+
+function Test-WelaWmiDescriptorPreserved {
+ param($Before, $After)
+ foreach ($property in $Before.PSObject.Properties) {
+ if ($property.Name -eq 'SACL') { continue }
+ if ($property.Name -eq 'ControlFlags') {
+ if ([uint32]$After.ControlFlags -ne ([uint32]$Before.ControlFlags -bor 16)) { return $false }
+ } elseif ((ConvertTo-WelaWmiJson $property.Value) -cne (ConvertTo-WelaWmiJson $After.($property.Name))) { return $false }
+ }
+ # Compare a multiset: providers can reorder a SACL, but cannot remove/change
+ # any original entry, including unknown types, trustee details or extra fields.
+ $remaining = New-Object 'System.Collections.Generic.List[string]'
+ foreach ($ace in @($After.SACL)) { if ($null -ne $ace) { $remaining.Add((ConvertTo-WelaWmiJson $ace)) } }
+ foreach ($ace in @($Before.SACL)) {
+ if ($null -eq $ace) { continue }
+ if (-not $remaining.Remove((ConvertTo-WelaWmiJson $ace))) { return $false }
+ }
+ return $true
+}
+
+function Get-WelaWmiNamespaceInventory {
+ $namespaces = @(Get-WelaWmiAuditDefinitions | Select-Object -ExpandProperty Namespace -Unique)
+ try {
+ $children = @(Get-CimInstance -Namespace root -ClassName __Namespace -ErrorAction Stop | ForEach-Object { 'root\' + $_.Name })
+ foreach ($namespace in $namespaces) { [pscustomobject]@{ Namespace = $namespace; State = $(if ($namespace -in $children) { 'Present' } else { 'NotInstalled' }) } }
+ } catch {
+ foreach ($namespace in $namespaces) { [pscustomobject]@{ Namespace = $namespace; State = 'Unknown'; Diagnostic = $_.Exception.Message } }
+ }
+}
+
+function Get-WelaWmiAuditPrerequisite {
+ try {
+ $mask = Get-WelaNativeAuditPolicy -Guid '0CCE9227-69AE-11D9-BED3-505054503030'
+ [pscustomobject]@{ Policy = 'Other Object Access Events'; Mask = $mask; SuccessEnabled = (($mask -band 1) -eq 1); State = 'Observed' }
+ } catch { [pscustomobject]@{ Policy = 'Other Object Access Events'; Mask = $null; SuccessEnabled = $null; State = 'Unknown'; Diagnostic = $_.Exception.Message } }
+}
+
+function Get-WelaWmiAuditPlan {
+ param([string[]]$Namespace, [switch]$IncludeChildren)
+ if (-not $Namespace.Count) { throw 'Select at least one exact namespace with -WmiNamespace; there is no implicit all-namespaces configuration.' }
+ $definitions = @(Get-WelaWmiAuditDefinitions -Namespace $Namespace -IncludeChildren:$IncludeChildren)
+ foreach ($name in @($definitions | Select-Object -ExpandProperty Namespace -Unique)) {
+ $selected = @($definitions | Where-Object Namespace -eq $name)
+ try {
+ $snapshot = Get-WelaWmiNamespaceSnapshot $name
+ $descriptor = $snapshot.DescriptorJson | ConvertFrom-Json
+ $missing = @(Get-WelaWmiMissingAces $descriptor $selected)
+ [pscustomobject]@{ Namespace = $name; Status = $(if ($missing.Count) { 'ChangeRequired' } else { 'AlreadyCompliant' }); Before = $snapshot; Definitions = $selected; Missing = $missing; Diagnostic = '' }
+ } catch { [pscustomobject]@{ Namespace = $name; Status = 'Unknown'; Before = $null; Definitions = $selected; Missing = @(); Diagnostic = $_.Exception.Message } }
+ }
+}
+
+function Set-WelaWmiAuditControls {
+ param($Context, [array]$Plan)
+ foreach ($entry in $Plan) {
+ $callback = @{ Namespace = $entry.Namespace; Definitions = $entry.Definitions; Original = $null; ExpectedJson = $null; Applied = $false; VerifiedJson = $null }
+ $read = {
+ param($state)
+ $snapshot = Get-WelaWmiNamespaceSnapshot $state.Namespace
+ if ($null -eq $state.Original) { $state.Original = $snapshot.DescriptorJson | ConvertFrom-Json; $state.ExpectedJson = $snapshot.DescriptorJson }
+ return $snapshot
+ }
+ $test = {
+ param($snapshot, $state)
+ $descriptor = $snapshot.DescriptorJson | ConvertFrom-Json
+ if (@(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count) { return $false }
+ if ($state.Applied) {
+ if (-not (Test-WelaWmiDescriptorPreserved $state.Original $descriptor)) { return $false }
+ if ($null -eq $state.VerifiedJson) { $state.VerifiedJson = $snapshot.DescriptorJson }
+ return $snapshot.DescriptorJson -ceq $state.VerifiedJson
+ }
+ # An already compliant descriptor still gets a full final drift check.
+ return $snapshot.DescriptorJson -ceq $state.ExpectedJson
+ }
+ $apply = {
+ param($state)
+ Set-WelaWmiNamespaceDescriptor -Namespace $state.Namespace -ExpectedJson $state.ExpectedJson -Definitions $state.Definitions
+ $state.Applied = $true
+ }
+ Invoke-WelaConfigurationControl -Context $Context -Id "WmiNamespace/$($entry.Namespace)/SACL" -Kind WmiNamespaceSacl `
+ -Target @{ Namespace = $entry.Namespace; Computer = 'Local'; Operation = 'Append audit ACEs only' } -Desired $entry.Definitions `
+ -Read $read -Compliant $test -Apply $apply -CallbackState $callback `
+ -Description ('Append missing success audit ACEs. Scope: ' + (($entry.Definitions.Scope | Select-Object -Unique) -join ', '))
+ }
+}
+
+function Invoke-WelaWmiAuditCommand {
+ param([ValidateSet('List', 'Audit', 'Plan', 'Configure')][string]$Action = 'List', [string[]]$Namespace,
+ [switch]$IncludeChildren, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
+ if ($env:OS -ne 'Windows_NT') { throw 'WMI namespace auditing requires Windows.' }
+ if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires -WmiAction Configure.' }
+ if ($Action -eq 'List') {
+ if ($Namespace -or $IncludeChildren) { throw 'List does not accept namespace or inheritance selections. Use Audit, Plan or Configure.' }
+ $inventory = @(Get-WelaWmiNamespaceInventory)
+ $report = [pscustomobject]@{ Scope = 'wmi-namespace-sacl-only'; Action = $Action; Namespaces = $inventory; ExitCode = $(if (@($inventory | Where-Object State -eq Unknown).Count) { 1 } else { 0 }) }
+ } else {
+ $plan = @(Get-WelaWmiAuditPlan -Namespace $Namespace -IncludeChildren:$IncludeChildren)
+ $prerequisite = Get-WelaWmiAuditPrerequisite
+ if ($Action -eq 'Configure') {
+ $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
+ Set-WelaWmiAuditControls -Context $context -Plan $plan
+ $report = Complete-WelaConfiguration -Context $context -Scope 'wmi-namespace-sacl-only' `
+ -SuccessMessage 'Selected WMI namespace SACLs verified; namespace access events and collection remain unverified.'
+ $report | Add-Member NoteProperty Prerequisite $prerequisite
+ } else { $report = [pscustomobject]@{ Scope = 'wmi-namespace-sacl-only'; Action = $Action; Controls = $plan; Prerequisite = $prerequisite; ExitCode = $(if (@($plan | Where-Object Status -eq Unknown).Count) { 1 } else { 0 }) } }
+ $report | Add-Member NoteProperty EventValidation 'Not performed. Namespace access auditing (Security 4662) is distinct from provider-operation success and local/remote WMI-Activity telemetry. Audit-policy readiness is observed separately; no usable-rule credit.'
+ }
+ if ($ResultsPath) {
+ try { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
+ catch { $report.ExitCode = 1; Write-Host "[Failed] Writing WMI results: $_" -ForegroundColor Red }
+ }
+ return $report
+}
diff --git a/tests/WmiNamespaceAuditing.Tests.ps1 b/tests/WmiNamespaceAuditing.Tests.ps1
new file mode 100644
index 00000000..0ccc0164
--- /dev/null
+++ b/tests/WmiNamespaceAuditing.Tests.ps1
@@ -0,0 +1,137 @@
+# In-memory descriptors only. All native readers/writers are replaced before control execution.
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path $PSScriptRoot -Parent
+$script:ScriptRoot = $repo
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1')
+$script:assertions = 0
+$root = Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-' + [guid]::NewGuid().ToString('N'))
+$null = New-Item -ItemType Directory -Path $root
+$fixture = Get-Content -LiteralPath (Join-Path $PSScriptRoot 'fixtures/wmi-namespace-descriptor.json') -Raw
+function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ }
+function Throws([scriptblock]$Action, [string]$Message) { $caught = $false; try { & $Action } catch { $caught = $true }; Assert $caught $Message }
+function Reset-Mocks {
+ $script:descriptor = $fixture | ConvertFrom-Json
+ $script:writes = 0; $script:reads = 0; $script:readFail = $false; $script:writeCode = 0
+ $script:race = $false; $script:alterOwner = $false; $script:dropUnknown = $false; $script:ineffective = $false
+ $script:decline = $false; $script:promptCallback = $null
+}
+function Get-WelaWmiNamespaceSnapshot {
+ param($Namespace)
+ $script:reads++
+ if ($script:readFail) { throw 'Access denied or namespace missing; no complete SACL available.' }
+ [pscustomobject]@{ Namespace = $Namespace; DescriptorJson = ConvertTo-WelaWmiJson $script:descriptor; DescriptorMof = 'mock full descriptor'; SaclReadPrivilege = 'mock assigned/enabled' }
+}
+function Set-WelaWmiNamespaceDescriptor {
+ param($Namespace, $ExpectedJson, $Definitions)
+ # Model the production immediate re-read and verify the generic runner journal.
+ $entry = @(Get-Content -LiteralPath (Join-Path $script:context.BackupPath 'before.jsonl') | ConvertFrom-Json)[-1]
+ Assert ($entry.Before.DescriptorJson -ceq $ExpectedJson -and $entry.Target.Namespace -eq $Namespace) 'Full recovery descriptor persisted before any setter'
+ Assert ($entry.Before.DescriptorMof -eq 'mock full descriptor') 'Journal includes native descriptor representation'
+ if ($script:race) { $script:descriptor.Owner.SIDString = 'S-1-5-18' }
+ if ((ConvertTo-WelaWmiJson $script:descriptor) -cne $ExpectedJson) { throw 'Namespace descriptor changed after its recovery snapshot; no SACL was written.' }
+ $script:writes++
+ Assert-WelaWmiReturnCode ([pscustomobject]@{ ReturnValue = $script:writeCode }) 'SetSecurityDescriptor'
+ if ($script:ineffective) { return }
+ foreach ($definition in @(Get-WelaWmiMissingAces $script:descriptor $Definitions)) {
+ $script:descriptor.SACL += [pscustomobject]@{ AccessMask = $definition.AccessMask; AceFlags = $definition.AceFlags; AceType = 2; Trustee = [pscustomobject]@{ SIDString = $definition.Sid } }
+ }
+ $script:descriptor.ControlFlags = [uint32]$script:descriptor.ControlFlags -bor 16
+ if ($script:alterOwner) { $script:descriptor.Owner.SIDString = 'S-1-5-18' }
+ if ($script:dropUnknown) { $script:descriptor.SACL = @($script:descriptor.SACL | Where-Object AceType -ne 19) }
+}
+function Read-Host { param($Prompt) if ($script:promptCallback) { & $script:promptCallback }; if ($script:decline) { 'n' } else { 'Y' } }
+function New-TestContext([switch]$DryRun, [switch]$Prompt) {
+ $script:context = New-WelaConfigurationContext -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath (Join-Path $root ([guid]::NewGuid().ToString('N')))
+ return $script:context
+}
+function Run-Controls { param($Context, [string[]]$Namespace = @('root\cimv2'), [switch]$IncludeChildren)
+ Set-WelaWmiAuditControls -Context $Context -Plan @(Get-WelaWmiAuditPlan -Namespace $Namespace -IncludeChildren:$IncludeChildren)
+}
+try {
+ $source = @(Get-WelaWmiAuditDefinitions -IncludeChildren)
+ Assert ($source.Count -eq 8) 'All eight pinned ASD entries are represented'
+ Assert (@($source.Namespace | Select-Object -Unique).Count -eq 5) 'Exactly five supported namespaces'
+ $expected = @('root\cimv2|262146|64|S-1-1-0', 'root\cimv2|1|64|S-1-5-4', 'root\cimv2|1|64|S-1-5-2', 'root\cimv2|1|64|S-1-5-3', 'root\SecurityCenter|262145|66|S-1-1-0', 'root\SecurityCenter2|262145|66|S-1-1-0', 'root\subscription|262174|66|S-1-1-0', 'root\default|262175|66|S-1-1-0')
+ foreach ($i in 0..7) { Assert (("$($source[$i].Namespace)|$($source[$i].AccessMask)|$($source[$i].AceFlags)|$($source[$i].Sid)") -eq $expected[$i]) 'Masks, principals and inheritance match pinned ASD source' }
+ Assert (@(Get-WelaWmiAuditDefinitions | Where-Object AceFlags -ne 64).Count -eq 0) 'Default does not extend auditing into unselected descendants'
+ Assert (@(Get-WelaWmiAuditDefinitions -Namespace @('ROOT\CIMV2','root\cimv2')).Count -eq 4) 'Case-insensitive duplicate selections do not duplicate ACE definitions'
+ foreach ($invalid in @('root\*','\\server\root\cimv2','root/cimv2','root\cimv2\child','root\default ')) { Throws { Get-WelaWmiAuditDefinitions -Namespace $invalid } 'Wildcards, remote paths and unreviewed namespace targets rejected' }
+ Throws { Get-WelaWmiAuditPlan } 'Empty selection refuses implicit configuration'
+ foreach ($value in @(2,8,9,21,4294967295,$null,$false,'unknown')) {
+ Throws { Assert-WelaWmiReturnCode ([pscustomobject]@{ReturnValue=$value}) 'GetSecurityDescriptor' } 'Every nonzero/missing/invalid return fails'
+ Throws { Assert-WelaWmiReturnCode ([pscustomobject]@{ReturnValue=$value}) 'SetSecurityDescriptor' } 'Every setter error is checked'
+ }
+ Assert-WelaWmiReturnCode ([pscustomobject]@{ReturnValue=[uint32]0}) 'GetSecurityDescriptor'
+ $getter = [pscustomobject]@{ Code = 2; Descriptor = [pscustomobject]@{ControlFlags=4} }
+ $getter | Add-Member ScriptMethod InvokeMethod { param($Name,$Parameters,$Options) [pscustomobject]@{ReturnValue=$this.Code;Descriptor=$this.Descriptor} }
+ Throws { Get-WelaWmiNativeDescriptor $getter } 'Production getter checks provider return code even when descriptor is populated'
+ $getter.Code=0; $getter.Descriptor=$null
+ Throws { Get-WelaWmiNativeDescriptor $getter } 'Production getter refuses missing descriptor even with success code'
+ $getter.Descriptor=[pscustomobject]@{ControlFlags=4;SACL=$null}
+ Assert ((Get-WelaWmiNativeDescriptor $getter).ControlFlags -eq 4) 'Production getter accepts explicit success and descriptor'
+ Assert ((Get-WelaWmiSid ([pscustomobject]@{ SID = [byte[]]@(1,1,0,0,0,0,0,1,0,0,0,0) })) -eq 'S-1-1-0') 'Binary SID identity supported without localized names'
+ Reset-Mocks
+ $before = $script:descriptor | ConvertTo-Json -Depth 30 | ConvertFrom-Json
+ $context = New-TestContext -DryRun
+ Run-Controls $context
+ Assert ($script:writes -eq 0 -and -not (Test-Path $context.BackupPath) -and $context.Results[0].Status -eq 'Skipped') 'Dry-run has no setter or journal mutation'
+ $context = New-TestContext -Prompt; $script:decline = $true
+ Run-Controls $context
+ Assert ($script:writes -eq 0 -and $context.Results[0].Diagnostic -match 'Declined') 'Operator decline has no setter'
+ Reset-Mocks
+ $context = New-TestContext
+ Run-Controls $context
+ Assert ($script:writes -eq 1 -and $context.Results[0].Status -eq 'Applied') 'One namespace update appends four missing CIMV2 ACEs'
+ Assert ((Complete-WelaConfiguration $context -Scope wmi-namespace-sacl-only).ExitCode -eq 0) 'Applied namespace passes final verification'
+ Assert (Test-WelaWmiDescriptorPreserved $before $script:descriptor) 'Owner/group/DACL/control flags and duplicate unknown ACEs preserved'
+ Assert ($script:descriptor.ControlFlags -eq (36868 -bor 16)) 'Only SACL_PRESENT is added to descriptor control flags'
+ Assert ($script:descriptor.SACL.Count -eq 7 -and @($script:descriptor.SACL | Where-Object AceType -eq 19).Count -eq 2) 'Unknown ACE multiplicity preserved'
+ $context = New-TestContext
+ Run-Controls $context
+ Assert ($script:writes -eq 1 -and $context.Results[0].Status -eq 'AlreadyCompliant') 'Second run does not duplicate entries'
+ $script:descriptor.DACL[0].AccessMask = 1
+ Assert ((Complete-WelaConfiguration $context).ExitCode -eq 1 -and $context.Results[0].Status -eq 'Overridden') 'Final already-compliant DACL drift is detected'
+ Reset-Mocks; $context = New-TestContext; $script:race = $true
+ Run-Controls $context
+ Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Changed owner between journal and setter refuses update'
+ Reset-Mocks; $context = New-TestContext -Prompt
+ $script:promptCallback = { $script:descriptor.SACL[1].OpaqueFutureField = @(99) }
+ Run-Controls $context
+ Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Unknown ACE changes during operator prompt are preserved by refusing write'
+ foreach ($failure in @('alterOwner','dropUnknown','ineffective')) {
+ Reset-Mocks; Set-Variable -Scope Script -Name $failure -Value $true; $context = New-TestContext
+ Run-Controls $context
+ Assert ($context.Results[0].Status -eq 'Failed' -and (Complete-WelaConfiguration $context).ExitCode -eq 1) 'Read-back rejects permission damage, dropped unknown ACE or ineffective update'
+ }
+ Reset-Mocks; $script:writeCode = 9; $context = New-TestContext
+ Run-Controls $context
+ Assert ($context.Results[0].Status -eq 'Failed' -and $script:descriptor.SACL.Count -eq 3) 'Provider return-code error is a failed control'
+ Reset-Mocks; $script:readFail = $true; $context = New-TestContext
+ Run-Controls $context
+ Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Unreadable/missing selected namespace fails without partial descriptor writes'
+ Reset-Mocks; $context = New-TestContext
+ Remove-Item -LiteralPath $context.BackupPath -Recurse
+ Run-Controls $context
+ Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Journal failure prevents setter invocation'
+ Reset-Mocks; $context = New-TestContext
+ Run-Controls $context
+ $script:descriptor.SACL += [pscustomobject]@{ AceType=2; AceFlags=128; AccessMask=1; Trustee=[pscustomobject]@{SIDString='S-1-5-18'} }
+ Assert ((Complete-WelaConfiguration $context).ExitCode -eq 1 -and $context.Results[0].Status -eq 'Overridden') 'Extra SACL drift after successful write is detected at final check'
+ Reset-Mocks; $context = New-TestContext
+ Run-Controls $context -Namespace 'root\subscription' -IncludeChildren
+ Assert ($script:descriptor.SACL[-1].AceFlags -eq 66 -and $script:descriptor.SACL[-1].AccessMask -eq 262174) 'Explicit child option enables exactly ASD inheritance for subscription'
+ $definition = @(Get-WelaWmiAuditDefinitions -Namespace 'root\cimv2')[0]
+ $ace = [pscustomobject]@{AceType=2;AceFlags=64;AccessMask=262146;Trustee=[pscustomobject]@{SIDString='S-1-1-0'}}
+ Assert (Test-WelaWmiAceMatch $ace $definition) 'Exact ordinary success ACE satisfies requirement'
+ foreach ($flags in @(80,192,66,72)) { $ace.AceFlags=$flags; Assert (-not (Test-WelaWmiAceMatch $ace $definition)) 'Inherited/broader/different-scope ACE does not hide a missing exact request' }
+ $tokens=$null;$parseErrors=$null
+ $ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'),[ref]$tokens,[ref]$parseErrors)
+ Assert ($parseErrors.Count -eq 0) 'Combined CLI parses'
+ # Execute only the actual top-level DryRun guard, with no command dispatch.
+ $guard=$ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith('if ($DryRun') } | Select-Object -First 1
+ $Cmd='wmi-auditing';$DryRun=$true;$WmiAction='Configure';$FirewallAction='Audit';$SmbAction='Audit'
+ & ([scriptblock]::Create($guard.Extent.Text));$WmiAction='Audit'
+ Throws { & ([scriptblock]::Create($guard.Extent.Text)) } 'WMI Audit rejects DryRun before dispatch'
+ Write-Host "PASS: $script:assertions WMI namespace assertions (mocked, no live namespace changes)."
+} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
diff --git a/tests/WmiNamespaceAuditing.Windows.Tests.ps1 b/tests/WmiNamespaceAuditing.Windows.Tests.ps1
new file mode 100644
index 00000000..7d38ca1f
--- /dev/null
+++ b/tests/WmiNamespaceAuditing.Windows.Tests.ps1
@@ -0,0 +1,60 @@
+# Actual reads and in-memory typed provider responses only. Never sends a native SetSecurityDescriptor.
+$ErrorActionPreference = 'Stop'
+if ($env:OS -ne 'Windows_NT') { Write-Host 'SKIP: Windows only'; return }
+$repo = Split-Path $PSScriptRoot -Parent
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1')
+$script:assertions = 0
+function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ }
+$before = Get-WelaWmiNamespaceSnapshot 'root\cimv2'
+Assert ($before.DescriptorJson -and $before.DescriptorMof -and $before.SaclReadPrivilege -eq 'SeSecurityPrivilege enabled') 'Actual privileged native descriptor read and full export'
+$inventory = @(Get-WelaWmiNamespaceInventory)
+Assert ($inventory.Count -eq 5 -and @($inventory | Where-Object { $_.Namespace -eq 'root\cimv2' -and $_.State -eq 'Present' }).Count -eq 1) 'Supported namespace inventory reads actual local namespaces'
+$plan = @(Get-WelaWmiAuditPlan -Namespace 'root\cimv2')
+Assert ($plan[0].Status -in @('AlreadyCompliant','ChangeRequired')) 'Actual CIMV2 plan reads successfully'
+$path = Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-readonly-' + [guid]::NewGuid().ToString('N'))
+$context = New-WelaConfigurationContext -Auto -DryRun -BackupPath $path
+Set-WelaWmiAuditControls -Context $context -Plan $plan
+Assert (-not (Test-Path $path) -and $context.Results[0].Status -in @('AlreadyCompliant','Skipped')) 'Real dry-run neither writes SACL nor creates journal'
+$after = Get-WelaWmiNamespaceSnapshot 'root\cimv2'
+Assert ($before.DescriptorJson -ceq $after.DescriptorJson) 'Full descriptor unchanged by read-only planning/dry-run'
+# Read actual native objects once; from here the native connection factory is
+# replaced in the same script scope before invoking ANY setter code.
+Initialize-WelaWmiInterop
+$privilege = New-Object Wela.WmiSecurityPrivilege
+$connection = $null
+try {
+ $connection = New-WelaWmiConnection 'root\cimv2'
+ $script:fixtureDescriptor = (Get-WelaWmiNativeDescriptor $connection).Clone()
+ $script:fixtureParameters = $connection.GetMethodParameters('SetSecurityDescriptor')
+} finally { if ($connection) { $connection.Dispose() }; $privilege.Dispose() }
+# An empty in-memory SACL forces all requested additions without changing Windows.
+$script:fixtureDescriptor.SACL = $null
+$expected = ConvertTo-WelaWmiJson (ConvertTo-WelaWmiData $script:fixtureDescriptor)
+$script:setCalls = 0; $script:captured = $null; $script:returnCode = [uint32]0
+$script:fake = [pscustomobject]@{}
+$script:fake | Add-Member ScriptMethod InvokeMethod {
+ param($Name, $Parameters, $Options)
+ if ($Name -eq 'GetSecurityDescriptor') { return [pscustomobject]@{ ReturnValue = [uint32]0; Descriptor = $script:fixtureDescriptor } }
+ if ($Name -ne 'SetSecurityDescriptor') { throw "Unexpected method: $Name" }
+ $script:setCalls++; $script:captured = $Parameters.Descriptor.Clone()
+ return [pscustomobject]@{ ReturnValue = $script:returnCode }
+}
+$script:fake | Add-Member ScriptMethod GetMethodParameters { param($Name) if ($Name -ne 'SetSecurityDescriptor') { throw 'Unexpected method parameters' }; return $script:fixtureParameters.Clone() }
+$script:fake | Add-Member ScriptMethod Dispose { }
+function New-WelaWmiConnection { param($Namespace) if ($Namespace -ne 'root\cimv2') { throw 'Unexpected fake target' }; return $script:fake }
+$definitions = @(Get-WelaWmiAuditDefinitions -Namespace 'root\cimv2')
+Set-WelaWmiNamespaceDescriptor -Namespace 'root\cimv2' -ExpectedJson $expected -Definitions $definitions
+Assert ($script:setCalls -eq 1 -and $script:captured -is [System.Management.ManagementBaseObject]) 'Production writer builds typed descriptor against fake provider only'
+$original = $expected | ConvertFrom-Json
+$captured = ConvertTo-WelaWmiData $script:captured
+Assert (Test-WelaWmiDescriptorPreserved $original $captured) 'Typed descriptor clone preserves DACL owner group and control flags'
+Assert (@(Get-WelaWmiMissingAces $captured $definitions).Count -eq 0 -and @($captured.SACL).Count -eq 4) 'Actual Win32_ACE/Trustee objects carry all four exact masks and binary SIDs'
+$script:returnCode = [uint32]9
+$failed = $false
+try { Set-WelaWmiNamespaceDescriptor -Namespace 'root\cimv2' -ExpectedJson $expected -Definitions $definitions } catch { $failed = $_.Exception.Message -match 'ReturnValue=9' }
+Assert $failed 'Production SetSecurityDescriptor wrapper rejects native nonzero return code'
+$prior = $script:setCalls; $failed = $false
+try { Set-WelaWmiNamespaceDescriptor -Namespace 'root\cimv2' -ExpectedJson '{}' -Definitions $definitions } catch { $failed = $_.Exception.Message -match 'changed after' }
+Assert ($failed -and $script:setCalls -eq $prior) 'Production writer detects changed snapshot before fake setter'
+Write-Host "PASS: $script:assertions Windows namespace read-only / in-memory native adapter assertions. No live SACL changes or event-generation claims."
diff --git a/tests/fixtures/wmi-namespace-descriptor.json b/tests/fixtures/wmi-namespace-descriptor.json
new file mode 100644
index 00000000..028db5a4
--- /dev/null
+++ b/tests/fixtures/wmi-namespace-descriptor.json
@@ -0,0 +1,15 @@
+{
+ "ControlFlags": 36868,
+ "DACL": [
+ {"AccessMask": 393279, "AceFlags": 2, "AceType": 0, "GuidObjectType": null, "GuidInheritedObjectType": null, "Trustee": {"SIDString": "S-1-5-32-544", "Name": "Administrators", "Domain": "BUILTIN"}},
+ {"AccessMask": 32, "AceFlags": 0, "AceType": 1, "Trustee": {"SIDString": "S-1-5-21-1-2-3-1001"}}
+ ],
+ "Group": {"SIDString": "S-1-5-18"},
+ "Owner": {"SIDString": "S-1-5-32-544"},
+ "SACL": [
+ {"AccessMask": 2, "AceFlags": 128, "AceType": 2, "Trustee": {"SIDString": "S-1-1-0"}},
+ {"AccessMask": 8, "AceFlags": 16, "AceType": 19, "GuidObjectType": "unfamiliar-object", "OpaqueFutureField": [1, 7, 255], "Trustee": {"SIDString": "S-1-5-18"}},
+ {"AccessMask": 8, "AceFlags": 16, "AceType": 19, "GuidObjectType": "unfamiliar-object", "OpaqueFutureField": [1, 7, 255], "Trustee": {"SIDString": "S-1-5-18"}}
+ ],
+ "ProviderExtension": {"Keep": "unchanged"}
+}
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 4d03a568..8fab8d1b 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,7 @@
**改善:**
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#372) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 50139bca..a19f7c0e 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,7 @@
**Improvements:**
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#372) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
From 9ffd2bd7585be40d1eba5e4a19b9c68ed1c79a8e Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:34:12 +0900
Subject: [PATCH 02/13] Assess native AppLocker readiness and guard audit-only
imports
---
.github/workflows/applocker-readiness.yml | 25 +++
CHANGELOG-Japanese.md | 2 +
CHANGELOG.md | 2 +
WELA.ps1 | 23 ++-
docs/applocker-readiness.md | 30 +++
scripts/AppLockerReadiness.ps1 | 213 +++++++++++++++++++++
tests/AppLockerReadiness.Tests.ps1 | 96 ++++++++++
tests/AppLockerReadiness.Windows.Tests.ps1 | 22 +++
website/docs/resources/changelog.ja.md | 2 +
website/docs/resources/changelog.md | 2 +
10 files changed, 415 insertions(+), 2 deletions(-)
create mode 100644 .github/workflows/applocker-readiness.yml
create mode 100644 docs/applocker-readiness.md
create mode 100644 scripts/AppLockerReadiness.ps1
create mode 100644 tests/AppLockerReadiness.Tests.ps1
create mode 100644 tests/AppLockerReadiness.Windows.Tests.ps1
diff --git a/.github/workflows/applocker-readiness.yml b/.github/workflows/applocker-readiness.yml
new file mode 100644
index 00000000..04e05238
--- /dev/null
+++ b/.github/workflows/applocker-readiness.yml
@@ -0,0 +1,25 @@
+name: AppLocker readiness tests
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ applocker-readiness:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Safety and readiness fixtures on Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/AppLockerReadiness.Tests.ps1
+ - name: Native read-only observations on Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/AppLockerReadiness.Windows.Tests.ps1
+ - name: Safety and readiness fixtures on PowerShell 7
+ shell: pwsh
+ run: ./tests/AppLockerReadiness.Tests.ps1
+ - name: Native read-only observations on PowerShell 7
+ shell: pwsh
+ run: ./tests/AppLockerReadiness.Windows.Tests.ps1
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index c79f4a86..53aed27c 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -52,6 +52,8 @@
**新機能:**
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (issue #381) (@Shirofune-Security)
+
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
- Defender for Identityの必要なログに対応した。 (#114) (@fukusuket)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab71489f..99e9d483 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,6 +54,8 @@
**New Features:**
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (issue #381) (@Shirofune-Security)
+
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)
- Support for Defender for Identity required logs. (#114) (@fukusuket)
diff --git a/WELA.ps1 b/WELA.ps1
index c8196e0a..ff50a3c5 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -22,6 +22,8 @@
[ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384,
[string]$HtmlPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit',
+ [ValidateSet('Audit', 'Plan', 'Import')][string]$AppLockerAction = 'Audit',
+ [string]$AppLockerPolicyPath,
[switch]$Help
)
@@ -38,6 +40,7 @@ $SaclTargetsPath = Join-Path $ScriptRoot "config/audit_sacl_targets.json"
. (Join-Path $ScriptRoot "scripts/Configuration.ps1")
. (Join-Path $ScriptRoot "scripts/FirewallLogging.ps1")
. (Join-Path $ScriptRoot "scripts/SmbAuditing.ps1")
+. (Join-Path $ScriptRoot "scripts/AppLockerReadiness.ps1")
Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
@@ -1673,6 +1676,8 @@ Usage:
./WELA.ps1 smb-auditing -SmbAction Audit -ResultsPath smb-audit.json
./WELA.ps1 smb-auditing -SmbAction Plan
./WELA.ps1 smb-auditing -SmbAction Configure -DryRun
+ ./WELA.ps1 applocker-readiness -ResultsPath applocker.json
+ ./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml
# SMB auditing is opt-in and never changes signing/encryption requirements or guest access.
./WELA.ps1 profiles # List versioned advanced audit-policy profiles
./WELA.ps1 plan -Profile wela-2.2.0 -Role Client -Build 26100 -PlanPath plan.json
@@ -1703,10 +1708,10 @@ Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
# Reject unsupported dry-run requests before reaching any command's mutation path.
-if ($DryRun -and $Cmd -notin @('configure', 'configure-eventlogs') -and
+if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure')) {
- throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure and smb-auditing -SmbAction Configure. No command was run."
+ throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure smb-auditing -SmbAction Configure, and applocker-readiness -AppLockerAction Import. No command was run."
}
if ($Profile -and $Cmd -in @('eventlog-profiles', 'audit-filesize', 'configure-eventlogs')) {
throw '-Profile selects advanced audit policy only. Use -LogProfile for event-log size/mode settings.'
@@ -1750,6 +1755,20 @@ switch ($Cmd.ToLower()) {
if ($report.ExitCode) { exit $report.ExitCode }
} catch { Write-Host "[Failed] SMB auditing: $_" -ForegroundColor Red; exit 1 }
}
+ "applocker-readiness" {
+ if ($Help) {
+ Write-Host 'Usage: ./WELA.ps1 applocker-readiness [-AppLockerAction Audit|Plan|Import] [-AppLockerPolicyPath operator.xml] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+ return
+ }
+ if ($AppLockerAction -eq 'Import' -and -not (TestAdministrator)) { throw 'AppLocker policy import requires Administrator privileges.' }
+ $report = Invoke-WelaAppLockerCommand -Action $AppLockerAction -PolicyPath $AppLockerPolicyPath -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
+ if ($report.PSObject.Properties['Assessment']) {
+ $report.Assessment.Collections | Format-Table Type, EnforcementMode, RuleCount, PrerequisiteState, GenerationReadiness -AutoSize
+ Write-Host 'GP observations only; CSP policies and actual event generation remain unverified.' -ForegroundColor Yellow
+ if ($report.ImportBlocker) { Write-Host "Import blocked: $($report.ImportBlocker)" -ForegroundColor Yellow }
+ } else { $report.Results | Format-Table Id, Status, Diagnostic -AutoSize }
+ if ($report.ExitCode -ne 0) { throw 'AppLocker assessment/import failed; see structured results.' }
+ }
"profiles" {
(Import-WelaAuditProfiles).profiles | Select-Object id, version, scope, appliesTo | Format-List
}
diff --git a/docs/applocker-readiness.md b/docs/applocker-readiness.md
new file mode 100644
index 00000000..ff3c93ec
--- /dev/null
+++ b/docs/applocker-readiness.md
@@ -0,0 +1,30 @@
+# Native AppLocker readiness
+
+`applocker-readiness` reports local and GP effective policy XML, each of the five rule collections, enforcement modes, rule counts, Application Identity (`AppIDSvc`) state/start mode and relevant AppLocker channel observations. A host with enabled channels but no rules reports `MissingGpPolicy`. Stopped/disabled services, missing channels, unavailable cmdlets and read errors remain explicit. `NotConfigured` with rules is treated as potential enforcement, never as disabled.
+
+```powershell
+./WELA.ps1 applocker-readiness -ResultsPath applocker.json
+./WELA.ps1 applocker-readiness -AppLockerAction Plan -AppLockerPolicyPath operator-audit.xml -ResultsPath plan.json
+./WELA.ps1 applocker-readiness -AppLockerAction Import -AppLockerPolicyPath operator-audit.xml -DryRun
+./WELA.ps1 applocker-readiness -AppLockerAction Import -AppLockerPolicyPath operator-audit.xml -BackupPath C:\WelaBackups\applocker-001 -ResultsPath imported.json
+```
+
+The default is read-only Audit. Windows 11 clients and member servers running Server 2016 or later are candidates; availability is checked through the actual native cmdlets and service. Edition names alone do not establish capability. Import requires a 64-bit elevated session. Ordinary `configure` does not invoke this workflow. No service, channel, application control enforcement or forwarding settings are automatically changed.
+
+## Scope and import safeguards
+
+Import accepts an **operator-supplied** native XML policy. Every included collection must explicitly be AuditOnly and contain rules. XML DTDs, namespaces, unknown collection types, duplicate IDs and policy extensions are rejected. The native `Test-AppLockerPolicy` cmdlet validates the prepared XML before it can be installed; it does not execute the test file. There are no generated blanket allow rules or default policy assumptions.
+
+Import only initializes an empty local/GP policy, or verifies an identical previously imported policy. Existing configured collections, existing enforcement (including NotConfigured collections with rules), unreadable policy, domain membership, observed enrollment/provider entries or unknown management state block import. Use the organization's policy authority to manage those hosts. The workflow uses `Set-AppLockerPolicy -Merge`, retains original policy XML in the recovery journal, rechecks state before writing, and verifies local collection content again after writing and at completion. It does not replace an existing policy. An import failure is reported with a nonzero exit code. Dry-run makes no policy or recovery-file changes.
+
+Microsoft's [Get-AppLockerPolicy documentation](https://learn.microsoft.com/en-us/powershell/module/applocker/get-applockerpolicy) limits that cmdlet to GP policies: **CSP policies are invisible**. Enrollment/provider observations are conservative blockers, not proof that CSP policy is absent. `CspPolicyState=Unknown` remains in every assessment; review other management mechanisms before choosing local import. The [merge semantics](https://learn.microsoft.com/en-us/powershell/module/applocker/set-applockerpolicy) preserve existing enforcement mode. Concurrent policy administration is not an atomic transaction with this workflow; keep the deployment window isolated and review the final readback. No automatic rollback overwrites newer policy.
+
+Recovery: keep the backup directory outside temporary folders. `before.jsonl` contains the original local and GP policy XML, service/channel/management observations and desired policy. The prepared imported XML is retained as `appLocker-audit-import.xml`. Compare them with a fresh audit before recovery; use the existing policy authority or Local Security Policy to remove only the policy created by this run. Do not blindly restore stale effective domain policy or remove someone else's new rules. Use an isolated machine snapshot for integration tests.
+
+## What readiness means
+
+`Conditional` means GP rules, a running service and enabled channels were observed. All collections still report `GenerationReadiness=Unverified` and zero usable-rule credit. A policy can omit rule collections, contain rules that do not match the relevant user/application, or be superseded later. Missing GP rules do not prove no CSP rules exist. A successful import verifies local policy content only; it does not start the service, validate an actual executable/script event or verify collector ingestion.
+
+[Microsoft WEF guidance](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) recommends at least an audit-only policy. See Microsoft's [audit-only configuration](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/configure-an-applocker-policy-for-audit-only), [requirements](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/requirements-to-use-applocker) and [rule enforcement behavior](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/working-with-applocker-rules). Native Windows functionality only; Sysmon is out of scope.
+
+Before closing issue #381, on an isolated patched Windows 11/member-server snapshot, export policy/service/channel state, import a reviewed audit-only policy, explicitly configure required service prerequisites, run a benign executable and script, and match the expected AppLocker event XML to their paths/user/rule collection. Confirm an enforced policy stays unchanged when this importer refuses it. Repeat for managed hosts and validate forwarding where required. CI only uses mocked mutations and actual read-only native policy/schema observations; it does not establish event generation or production deployment safety.
diff --git a/scripts/AppLockerReadiness.ps1 b/scripts/AppLockerReadiness.ps1
new file mode 100644
index 00000000..89753bc0
--- /dev/null
+++ b/scripts/AppLockerReadiness.ps1
@@ -0,0 +1,213 @@
+# Native AppLocker observations and a deliberately narrow local audit-only import.
+function ConvertFrom-WelaAppLockerXml {
+ param([Parameter(Mandatory)][string]$Xml, [switch]$ForImport)
+ $settings = New-Object Xml.XmlReaderSettings
+ $settings.DtdProcessing = [Xml.DtdProcessing]::Prohibit; $settings.XmlResolver = $null
+ $settings.MaxCharactersInDocument = 10485760
+ $reader = [Xml.XmlReader]::Create((New-Object IO.StringReader($Xml)), $settings)
+ try {
+ $doc = New-Object Xml.XmlDocument; $doc.XmlResolver = $null
+ $doc.Load($reader)
+ } finally { $reader.Dispose() }
+ if ($doc.DocumentElement.LocalName -cne 'AppLockerPolicy' -or $doc.DocumentElement.NamespaceURI -or $doc.DocumentElement.GetAttribute('Version') -ne '1') { throw 'Expected unqualified AppLockerPolicy Version=1.' }
+ $collections = New-Object 'System.Collections.Generic.List[object]'
+ $types = @{}; $ids = @{}
+ foreach ($node in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
+ if ($node.LocalName -ne 'RuleCollection') { throw "Unsupported AppLocker policy element: $($node.LocalName)" }
+ $type = $node.GetAttribute('Type'); $mode = $node.GetAttribute('EnforcementMode')
+ if ($type -notin @('Exe', 'Dll', 'Msi', 'Script', 'Appx') -or $types.ContainsKey($type)) { throw "Unknown/duplicate rule collection: $type" }
+ if ($mode -notin @('Enabled', 'AuditOnly', 'NotConfigured')) { throw "Unknown enforcement mode: $mode" }
+ $types[$type] = $true
+ $rules = @($node.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -in @('FilePathRule', 'FilePublisherRule', 'FileHashRule') })
+ if ($ForImport) {
+ if ($mode -ne 'AuditOnly' -or -not $rules.Count) { throw 'Every imported collection must explicitly be AuditOnly and contain rules.' }
+ if (@($node.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('FilePathRule', 'FilePublisherRule', 'FileHashRule') }).Count) { throw 'Policy extensions/unknown rule elements are not accepted for import.' }
+ foreach ($rule in $rules) {
+ $guid = [guid]::Empty
+ if (-not [guid]::TryParse($rule.GetAttribute('Id'), [ref]$guid) -or $ids.ContainsKey($guid.ToString())) { throw 'Rule IDs must be valid and globally unique.' }
+ $ids[$guid.ToString()] = $true
+ if ($rule.GetAttribute('Action') -notin @('Allow', 'Deny') -or $rule.GetAttribute('UserOrGroupSid') -notmatch '^S-1-\d+(-\d+)+$' -or -not $rule.GetAttribute('Name')) { throw 'Invalid rule action, SID or name.' }
+ if (@($rule.SelectNodes('./Conditions')).Count -ne 1 -or -not $rule.SelectSingleNode('./Conditions/*')) { throw 'Each rule must have conditions.' }
+ # Reject hidden extension nodes and namespaces; Windows validates the
+ # complete native rule schema before applying the prepared snapshot.
+ if (@($rule.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('Conditions', 'Exceptions') }).Count) { throw 'Unknown rule child element.' }
+ }
+ }
+ $collections.Add([pscustomobject]@{ Type=$type; EnforcementMode=$mode; RuleCount=$rules.Count; PotentialEnforcement=($mode -eq 'Enabled' -or ($mode -eq 'NotConfigured' -and $rules.Count -gt 0)); Xml=$node.OuterXml })
+ }
+ if ($ForImport -and -not $collections.Count) { throw 'An empty policy cannot supply AppLocker generation prerequisites.' }
+ if ($ForImport -and @($doc.SelectNodes('//*') | Where-Object { $_.NamespaceURI -or @($_.Attributes | Where-Object { $_.NamespaceURI }).Count }).Count) { throw 'Namespaced policy elements/attributes are not accepted for import.' }
+ [pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); TotalRules=(@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
+}
+
+function Get-WelaAppLockerHost {
+ try {
+ $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property BuildNumber, ProductType, Caption -ErrorAction Stop
+ $computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property PartOfDomain -ErrorAction Stop
+ if (-not $os -or $os.BuildNumber -notmatch '^\d+$' -or $null -eq $computer -or $computer.PartOfDomain -isnot [bool]) { throw 'Host applicability or management state is unknown.' }
+ $eligible = ($os.ProductType -eq 1 -and [int]$os.BuildNumber -ge 22000) -or ($os.ProductType -eq 3 -and [int]$os.BuildNumber -ge 14393)
+ $state = if ($eligible) { 'Candidate' } else { 'NotApplicable' }
+ [pscustomobject]@{ Status=$state; Build=[int]$os.BuildNumber; ProductType=[int]$os.ProductType; Caption=[string]$os.Caption; PartOfDomain=$computer.PartOfDomain; Is64BitProcess=[Environment]::Is64BitProcess; Diagnostic='Native cmdlet/service observations determine capability; no edition-only inference. Import scope is local client/member server.' }
+ } catch { [pscustomobject]@{ Status='Unknown'; Diagnostic=$_.Exception.Message } }
+}
+
+function Get-WelaAppLockerPolicySnapshot {
+ param([ValidateSet('Local', 'Effective')][string]$Scope)
+ try {
+ if (-not (Get-Command Get-AppLockerPolicy -ErrorAction SilentlyContinue)) { return [pscustomobject]@{ Status='CmdletUnavailable'; Policy=$null; Diagnostic='Get-AppLockerPolicy is unavailable in this PowerShell session; capability is unverified.' } }
+ $arguments = @{ Xml=$true; ErrorAction='Stop' }; $arguments[$Scope] = $true
+ $xml = [string](Get-AppLockerPolicy @arguments)
+ [pscustomobject]@{ Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $xml); Diagnostic='GP policy only. AppLocker CSP policy is not visible to this cmdlet.' }
+ } catch { [pscustomobject]@{ Status='Unknown'; Policy=$null; Diagnostic=$_.Exception.Message } }
+}
+
+function Get-WelaAppLockerService {
+ try {
+ $service = Get-CimInstance -ClassName Win32_Service -Filter "Name='AppIDSvc'" -ErrorAction Stop
+ if (-not $service) { return [pscustomobject]@{ Status='NotInstalled'; State=$null; StartMode=$null; Diagnostic='Application Identity service was not found.' } }
+ [pscustomobject]@{ Status='Observed'; State=[string]$service.State; StartMode=[string]$service.StartMode; Diagnostic='Service state observed; no service changes were made.' }
+ } catch { [pscustomobject]@{ Status='Unknown'; State=$null; StartMode=$null; Diagnostic=$_.Exception.Message } }
+}
+
+function Get-WelaAppLockerChannels {
+ foreach ($name in @('EXE and DLL', 'MSI and Script', 'Packaged app-Execution', 'Packaged app-Deployment')) {
+ $channel = "Microsoft-Windows-AppLocker/$name"
+ try {
+ $log = Get-WinEvent -ListLog $channel -ErrorAction Stop
+ if (-not $log -or $log.LogName -ne $channel) { throw 'Channel read did not return the requested channel.' }
+ [pscustomobject]@{ Channel=$channel; Status='Observed'; Enabled=[bool]$log.IsEnabled; Diagnostic='Channel enablement is not proof of event generation.' }
+ } catch {
+ $state = if ($_.FullyQualifiedErrorId -like 'NoMatchingLogsFound*') { 'NotInstalled' } else { 'Unknown' }
+ [pscustomobject]@{ Channel=$channel; Status=$state; Enabled=$null; Diagnostic=$_.Exception.Message }
+ }
+ }
+}
+
+function Get-WelaAppLockerManagement {
+ # These are blockers, not an assertion that CSP policy is absent. The native
+ # cmdlets cannot read CSP; import is confined to apparently unmanaged hosts.
+ try {
+ $present = @()
+ foreach ($path in @('HKLM:\SOFTWARE\Microsoft\Enrollments', 'HKLM:\SOFTWARE\Microsoft\PolicyManager\Providers')) {
+ if (Test-Path -LiteralPath $path -ErrorAction Stop) {
+ $present += @(Get-ChildItem -LiteralPath $path -ErrorAction Stop | Where-Object { $_.PSChildName -match '^\{?[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?$' } | ForEach-Object { $_.Name })
+ }
+ }
+ [pscustomobject]@{ Status='Observed'; ManagementEntries=$present; CspPolicyState='Unknown'; Diagnostic='No CSP policy completeness claim. Any observed enrollment/provider blocks local import.' }
+ } catch { [pscustomobject]@{ Status='Unknown'; ManagementEntries=@(); CspPolicyState='Unknown'; Diagnostic=$_.Exception.Message } }
+}
+
+function Get-WelaAppLockerReadiness {
+ $hostState = Get-WelaAppLockerHost
+ $local = Get-WelaAppLockerPolicySnapshot Local; $effective = Get-WelaAppLockerPolicySnapshot Effective
+ $service = Get-WelaAppLockerService; $channels = @(Get-WelaAppLockerChannels)
+ $rows = foreach ($type in @('Exe', 'Dll', 'Msi', 'Script', 'Appx')) {
+ $collection = @($effective.Policy.Collections | Where-Object Type -eq $type) | Select-Object -First 1
+ $names = switch ($type) { 'Exe' { 'EXE and DLL' } 'Dll' { 'EXE and DLL' } 'Msi' { 'MSI and Script' } 'Script' { 'MSI and Script' } 'Appx' { 'Packaged app-Execution'; 'Packaged app-Deployment' } }
+ $logs = @($channels | Where-Object { $_.Channel.Substring('Microsoft-Windows-AppLocker/'.Length) -in $names })
+ $state = if ($hostState.Status -eq 'NotApplicable') { 'NotApplicable' }
+ elseif ($hostState.Status -ne 'Candidate' -or $effective.Status -ne 'Observed' -or $service.Status -eq 'Unknown') { 'Unknown' }
+ elseif (-not $collection -or $collection.RuleCount -eq 0) { 'MissingGpPolicy' }
+ elseif ($service.Status -eq 'NotInstalled') { 'NotInstalled' }
+ elseif ($service.StartMode -eq 'Disabled' -or $service.State -ne 'Running') { 'ServiceNotRunning' }
+ elseif (@($logs | Where-Object Status -ne 'Observed').Count) { 'ChannelUnknown' }
+ elseif (@($logs | Where-Object { -not $_.Enabled }).Count) { 'ChannelDisabled' }
+ else { 'Conditional' }
+ [pscustomobject]@{ Type=$type; EnforcementMode=if ($collection) {$collection.EnforcementMode} else {$null}; RuleCount=if ($collection) {$collection.RuleCount} else {0}; PotentialEnforcement=if ($collection) {$collection.PotentialEnforcement} else {$false}; PrerequisiteState=$state; Channels=$logs; GenerationReadiness='Unverified'; Diagnostic='Local/GP observations only; CSP policies and actual executable/script event XML require separate verification.' }
+ }
+ [pscustomobject]@{ Scope='native-applocker-readiness'; Host=$hostState; LocalPolicy=$local; EffectiveGpPolicy=$effective; Service=$service; Collections=@($rows); Management=(Get-WelaAppLockerManagement); CspPolicyState='Unknown'; UsableRuleCredit=0; GenerationReadiness='Unverified' }
+}
+
+function Get-WelaAppLockerXmlKey {
+ param([string]$Xml)
+ # Compare policy meaning without treating native XML formatting/attribute
+ # ordering as a failed write. Rule IDs are unique, so rule order is immaterial.
+ $document = New-Object Xml.XmlDocument; $document.XmlResolver=$null; $document.LoadXml($Xml)
+ function Convert-WelaAppLockerNodeKey($Node) {
+ $attributes = @($Node.Attributes | Where-Object { -not ($_.LocalName -eq 'Description' -and $_.Value -eq '') } | Sort-Object Name | ForEach-Object { @($_.Name, $_.Value) -join '=' })
+ $children = @($Node.ChildNodes | Where-Object NodeType -eq Element | ForEach-Object { Convert-WelaAppLockerNodeKey $_ } | Sort-Object)
+ # JSON arrays delimit values so attribute/condition text cannot collide.
+ return ConvertTo-Json -InputObject @($Node.LocalName, $attributes, $children) -Depth 20 -Compress
+ }
+ Convert-WelaAppLockerNodeKey $document.DocumentElement
+}
+
+function Test-WelaAppLockerPolicyMatch {
+ param($Snapshot, $Desired)
+ if ($Snapshot.LocalPolicy.Status -ne 'Observed') { return $false }
+ $current = $Snapshot.LocalPolicy.Policy
+ if ($current.Collections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement) { return $false }
+ foreach ($wanted in $Desired.Collections) {
+ $actual = @($current.Collections | Where-Object Type -eq $wanted.Type)
+ if ($actual.Count -ne 1 -or (Get-WelaAppLockerXmlKey $actual[0].Xml) -cne (Get-WelaAppLockerXmlKey $wanted.Xml)) { return $false }
+ }
+ return $true
+}
+
+function Assert-WelaAppLockerImportSafe {
+ param($Snapshot, $Desired)
+ if ($Snapshot.Host.Status -ne 'Candidate' -or -not $Snapshot.Host.Is64BitProcess) { throw 'Local import requires a supported 64-bit Windows client/member-server session.' }
+ if ($Snapshot.Host.PartOfDomain -or $Snapshot.Management.Status -ne 'Observed' -or @($Snapshot.Management.ManagementEntries).Count) { throw 'Local import is blocked on domain-joined, managed or unknown-management hosts. Deploy through the existing policy authority.' }
+ if ($Snapshot.LocalPolicy.Status -ne 'Observed' -or $Snapshot.EffectiveGpPolicy.Status -ne 'Observed') { throw 'Both local and GP effective policies must be readable.' }
+ if ($Snapshot.LocalPolicy.Policy.HasEnforcement -or $Snapshot.EffectiveGpPolicy.Policy.HasEnforcement) { throw 'Existing enforcement (including NotConfigured collections with rules) is preserved; audit-only import is blocked.' }
+ if (Test-WelaAppLockerPolicyMatch -Snapshot $Snapshot -Desired $Desired) { return }
+ if ($Snapshot.LocalPolicy.Policy.Collections.Count -or $Snapshot.EffectiveGpPolicy.Policy.Collections.Count) { throw 'Existing policy is preserved. Import only initializes an empty local/GP policy; it never replaces a configured policy.' }
+}
+
+function Set-WelaAppLockerAuditPolicy {
+ param($Context, $Desired)
+ $state = @{ Desired=$Desired; Before=$null; Context=$Context }
+ $read = { param($state) $snapshot = Get-WelaAppLockerReadiness; Assert-WelaAppLockerImportSafe $snapshot $state.Desired; $state.Before=$snapshot; return $snapshot }
+ $test = { param($snapshot, $state) Test-WelaAppLockerPolicyMatch $snapshot $state.Desired }
+ $apply = {
+ param($state)
+ $fresh = Get-WelaAppLockerReadiness
+ Assert-WelaAppLockerImportSafe $fresh $state.Desired
+ if ($fresh.LocalPolicy.Policy.Xml -cne $state.Before.LocalPolicy.Policy.Xml -or $fresh.EffectiveGpPolicy.Policy.Xml -cne $state.Before.EffectiveGpPolicy.Policy.Xml) { throw 'AppLocker policy changed after the recovery snapshot; no policy was imported.' }
+ if (-not (Get-Command Set-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Set-AppLockerPolicy is unavailable in this session.' }
+ # Import the validated in-memory snapshot, not a mutable operator source file.
+ $path = Join-Path $state.Context.BackupPath 'appLocker-audit-import.xml'
+ [IO.File]::WriteAllText($path, $state.Desired.Xml, (New-Object Text.UTF8Encoding($false)))
+ if (-not (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Test-AppLockerPolicy is unavailable; native schema validation is required before import.' }
+ # Deny concurrent modification/deletion of the prepared XML while both
+ # native cmdlets consume it; they need only read access.
+ $lock = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ try {
+ $validation = @(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
+ if (-not $validation.Count) { throw 'Native policy validation returned no result; no policy was imported.' }
+ $immediate = Get-WelaAppLockerReadiness
+ Assert-WelaAppLockerImportSafe $immediate $state.Desired
+ if ($immediate.LocalPolicy.Policy.Xml -cne $state.Before.LocalPolicy.Policy.Xml -or $immediate.EffectiveGpPolicy.Policy.Xml -cne $state.Before.EffectiveGpPolicy.Policy.Xml) { throw 'Policy changed during native validation; no policy was imported.' }
+ Set-AppLockerPolicy -XmlPolicy $path -Merge -ErrorAction Stop
+ } finally { $lock.Dispose() }
+ 'Audit-only local policy merged. Service, event generation, CSP state and future policy refresh are not configured or verified.'
+ }
+ Invoke-WelaConfigurationControl -Context $Context -Id 'AppLocker/LocalAuditOnlyPolicy' -Kind AppLocker -Target 'Local GPO' -Desired $Desired `
+ -Read $read -Compliant $test -Apply $apply -CallbackState $state -Description 'Initialize empty local AppLocker policy from this operator-supplied audit-only XML; preserve existing policies.'
+}
+
+function Invoke-WelaAppLockerCommand {
+ param([ValidateSet('Audit','Plan','Import')][string]$Action='Audit', [string]$PolicyPath, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
+ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'AppLocker readiness requires Windows.' }
+ if ($DryRun -and $Action -ne 'Import') { throw '-DryRun applies only to AppLockerAction Import.' }
+ if ($Action -eq 'Import' -and -not $PolicyPath) { throw '-AppLockerPolicyPath is required for Import.' }
+ $desired = $null
+ if ($PolicyPath) { $desired = ConvertFrom-WelaAppLockerXml -Xml (Get-Content -LiteralPath $PolicyPath -Raw -ErrorAction Stop) -ForImport }
+ if ($Action -eq 'Import') {
+ $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
+ Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ $report = Complete-WelaConfiguration -Context $context -Scope 'native-windows-configuration' -SuccessMessage 'Requested local audit-only policy verified; AppLocker event generation remains unverified.'
+ $report | Add-Member NoteProperty VerificationScope 'Local audit-only policy readback only; no service changes, CSP assessment, event-generation or forwarding verification.'
+ } else {
+ $assessment = Get-WelaAppLockerReadiness
+ $blocker = $null
+ if ($desired) { try { Assert-WelaAppLockerImportSafe $assessment $desired } catch { $blocker=$_.Exception.Message } }
+ $report = [pscustomobject]@{ Scope='native-applocker-readiness'; Action=$Action; Assessment=$assessment; ProposedAuditPolicy=$desired; ImportBlocker=$blocker; ExitCode=0 }
+ if ($assessment.Host.Status -eq 'Unknown' -or $assessment.LocalPolicy.Status -in @('Unknown','CmdletUnavailable') -or $assessment.EffectiveGpPolicy.Status -in @('Unknown','CmdletUnavailable')) { $report.ExitCode=1 }
+ }
+ if ($ResultsPath) {
+ try { $report | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
+ catch { $report.ExitCode=1; Write-Host "[Failed] Writing AppLocker results: $_" -ForegroundColor Red }
+ }
+ return $report
+}
diff --git a/tests/AppLockerReadiness.Tests.ps1 b/tests/AppLockerReadiness.Tests.ps1
new file mode 100644
index 00000000..b805578e
--- /dev/null
+++ b/tests/AppLockerReadiness.Tests.ps1
@@ -0,0 +1,96 @@
+$ErrorActionPreference = 'Stop'
+. (Join-Path $PSScriptRoot '../scripts/Configuration.ps1')
+. (Join-Path $PSScriptRoot '../scripts/AppLockerReadiness.ps1')
+$count=0
+function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ }
+function Assert-Throws([scriptblock]$Action,$Pattern) { $message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message }; Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'." }
+$xml=''
+$desired=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
+Assert ($desired.TotalRules -eq 1 -and -not $desired.HasEnforcement) 'Audit-only rule must parse.'
+Assert ((Get-WelaAppLockerXmlKey $xml) -ceq (Get-WelaAppLockerXmlKey ($xml.Replace('Type="Exe" EnforcementMode="AuditOnly"', 'EnforcementMode="AuditOnly" Type="Exe"')))) 'Attribute ordering cannot change compliance.'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('AuditOnly','Enabled')) -ForImport } 'AuditOnly'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('AuditOnly','NotConfigured')) -ForImport } 'AuditOnly'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml '' -ForImport } 'empty'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml (']>'+ $xml) -ForImport } 'DTD'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('', '')) -ForImport } 'extensions'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('12345678-1234-1234-1234-123456789abc','not-a-guid')) -ForImport } 'IDs'
+$implicit=ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('AuditOnly','NotConfigured'))
+Assert ($implicit.HasEnforcement) 'NotConfigured with rules may enforce; never call it disabled.'
+function Reset-Fixture {
+ $script:localXml=''; $script:effectiveXml=$script:localXml
+ $script:serviceState='Running'; $script:serviceMode='Auto'; $script:channelEnabled=$true
+ $script:domain=$false; $script:managed=@(); $script:unknownPolicy=$false; $script:writes=0; $script:readCount=0
+ $script:race=$false; $script:reject=$false; $script:drift=$false
+}
+function Get-WelaAppLockerHost { [pscustomobject]@{Status='Candidate'; Is64BitProcess=$true; PartOfDomain=$script:domain} }
+function Get-WelaAppLockerManagement { [pscustomobject]@{Status='Observed'; ManagementEntries=$script:managed; CspPolicyState='Unknown'} }
+function Get-WelaAppLockerService { [pscustomobject]@{Status='Observed'; State=$script:serviceState; StartMode=$script:serviceMode} }
+function Get-WelaAppLockerChannels { foreach ($name in @('EXE and DLL','MSI and Script','Packaged app-Execution','Packaged app-Deployment')) { [pscustomobject]@{Channel="Microsoft-Windows-AppLocker/$name"; Status='Observed'; Enabled=$script:channelEnabled} } }
+function Get-WelaAppLockerPolicySnapshot {
+ param($Scope)
+ if ($Scope -eq 'Local') {
+ $script:readCount++
+ if ($script:race -and $script:readCount -eq 2) { $script:localXml=$xml.Replace('AuditOnly','Enabled') }
+ if ($script:drift -and $script:readCount -ge 5) { $script:localXml='' }
+ }
+ if ($script:unknownPolicy) { return [pscustomobject]@{Status='Unknown'; Policy=$null} }
+ $value=if ($Scope -eq 'Local') {$script:localXml} else {$script:effectiveXml}
+ [pscustomobject]@{Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $value)}
+}
+function Test-AppLockerPolicy { [CmdletBinding()]param($XmlPolicy,$Path,$User) [pscustomobject]@{PolicyDecision='Allowed'} }
+function Set-AppLockerPolicy {
+ [CmdletBinding()]param($XmlPolicy,[switch]$Merge)
+ if (-not $Merge) { throw 'Import must never replace a policy.' }
+ $script:writes++
+ if ($script:reject) { throw 'native rejected policy' }
+ $script:localXml=[IO.File]::ReadAllText($XmlPolicy); $script:effectiveXml=$script:localXml
+}
+Reset-Fixture
+$empty=Get-WelaAppLockerReadiness
+Assert (@($empty.Collections | Where-Object PrerequisiteState -ne MissingGpPolicy).Count -eq 0) 'Enabled channels without rules must retain a missing GP policy prerequisite.'
+Assert ($empty.CspPolicyState -eq 'Unknown' -and $empty.UsableRuleCredit -eq 0) 'GP readback never establishes CSP or detection readiness.'
+$script:localXml=$xml; $script:effectiveXml=$xml
+$ready=Get-WelaAppLockerReadiness
+Assert ($ready.Collections[0].PrerequisiteState -eq 'Conditional' -and $ready.Collections[0].GenerationReadiness -eq 'Unverified') 'Audit policy plus service/channel is only conditional.'
+$script:serviceState='Stopped'; $script:serviceMode='Disabled'
+Assert ((Get-WelaAppLockerReadiness).Collections[0].PrerequisiteState -eq 'ServiceNotRunning') 'Disabled service must be explicit.'
+$script:serviceState='Running';$script:serviceMode='Auto';$script:channelEnabled=$false
+Assert ((Get-WelaAppLockerReadiness).Collections[0].PrerequisiteState -eq 'ChannelDisabled') 'Disabled channel must be explicit.'
+Reset-Fixture; $script:effectiveXml=$xml.Replace('AuditOnly','Enabled')
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'enforcement'
+Reset-Fixture; $script:localXml=$xml.Replace('AuditOnly','NotConfigured')
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'enforcement'
+Reset-Fixture; $script:domain=$true
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'domain-joined'
+Reset-Fixture; $script:managed=@('MDM provider')
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'managed'
+Reset-Fixture; $script:unknownPolicy=$true
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'readable'
+$cleanup=@()
+try {
+ foreach ($scenario in @('apply','dry','race','failure','drift','existing')) {
+ Reset-Fixture
+ if ($scenario -eq 'race') {$script:race=$true}
+ if ($scenario -eq 'failure') {$script:reject=$true}
+ if ($scenario -eq 'drift') {$script:drift=$true}
+ if ($scenario -eq 'existing') {$script:localXml=$xml;$script:effectiveXml=$xml}
+ $path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-'+[guid]::NewGuid().ToString('N'));$cleanup+=$path
+ $context=New-WelaConfigurationContext -Auto -DryRun:($scenario -eq 'dry') -BackupPath $path
+ Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ $result=Complete-WelaConfiguration -Context $context
+ switch ($scenario) {
+ 'apply' {
+ Assert ($script:writes -eq 1 -and $result.ExitCode -eq 0) 'Verified initial audit-only merge should pass.'
+ Assert (Test-Path (Join-Path $path 'before.jsonl')) 'Recovery journal must precede merge.'
+ Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ Assert ($script:writes -eq 1 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Reapplying same policy should not write.'
+ }
+ 'dry' { Assert ($script:writes -eq 0 -and -not (Test-Path $path)) 'Dry-run must not write policy or recovery files.' }
+ 'race' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 1) 'Concurrent enforcement must block merge.' }
+ 'failure' { Assert ($result.ExitCode -eq 1) 'Native write failure must propagate.' }
+ 'drift' { Assert ($result.ExitCode -eq 1) 'Final readback must detect policy drift.' }
+ 'existing' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 0) 'Identical policy stays unchanged.' }
+ }
+ }
+} finally { foreach ($path in $cleanup) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } }
+Write-Host "PASS: $count AppLocker readiness/import assertions; no Windows policies changed."
diff --git a/tests/AppLockerReadiness.Windows.Tests.ps1 b/tests/AppLockerReadiness.Windows.Tests.ps1
new file mode 100644
index 00000000..ce75184a
--- /dev/null
+++ b/tests/AppLockerReadiness.Windows.Tests.ps1
@@ -0,0 +1,22 @@
+$ErrorActionPreference='Stop'
+if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Windows required.' }
+. (Join-Path $PSScriptRoot '../scripts/AppLockerReadiness.ps1')
+$report=Get-WelaAppLockerReadiness
+if ($report.Collections.Count -ne 5 -or $report.CspPolicyState -ne 'Unknown' -or $report.UsableRuleCredit -ne 0) { throw 'Native report lost collection/CSP uncertainty.' }
+if ($report.Host.Status -eq 'Unknown') { throw ($report.Host | ConvertTo-Json) }
+foreach ($scope in @($report.LocalPolicy,$report.EffectiveGpPolicy)) {
+ if ($scope.Status -eq 'Observed' -and -not $scope.Policy.Xml) { throw 'Observed policy must retain XML evidence.' }
+ if ($scope.Status -ne 'Observed') { Write-Host "Policy read limitation: $($scope.Status) $($scope.Diagnostic)" }
+}
+# The native cmdlet parses the XML without installing it or executing the file.
+if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
+ $path=Join-Path $env:TEMP ('wela-applocker-schema-'+[guid]::NewGuid().ToString('N')+'.xml')
+ try {
+ $xml=''
+ $policy=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
+ [IO.File]::WriteAllText($path,$policy.Xml)
+ $validation=@(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
+ if (-not $validation.Count) { throw 'Native schema validation returned no decision.' }
+ } finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }
+} else { Write-Host 'Native policy validation unavailable in this PowerShell session; importer will refuse.' }
+Write-Host 'PASS: native read-only AppLocker observations. No Set-AppLockerPolicy or service changes.'
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 4d03a568..68fa9ae3 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -55,6 +55,8 @@
**新機能:**
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (issue #381) (@Shirofune-Security)
+
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
- Defender for Identityの必要なログに対応した。 (#114) (@fukusuket)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 50139bca..0c942e93 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -57,6 +57,8 @@
**New Features:**
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (issue #381) (@Shirofune-Security)
+
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)
- Support for Defender for Identity required logs. (#114) (@fukusuket)
From 45ab6bcc8cd832a483961276a5ba3bce8d151967 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:34:58 +0900
Subject: [PATCH 03/13] Reference PR 399 in bilingual changelogs
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index bc0e29e0..28492827 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#372) (@Shirofune-Security)
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e45fed6e..0a164b0a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#372) (@Shirofune-Security)
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 8fab8d1b..7c4cda0f 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#372) (@Shirofune-Security)
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index a19f7c0e..3f5e1e26 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#372) (@Shirofune-Security)
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
From 6fbef0ff6b6c61a1ad42d14e83e9db5799b55580 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:35:09 +0900
Subject: [PATCH 04/13] Reference PR 400 in bilingual changelogs
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
4 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 53aed27c..3445d17c 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -52,7 +52,7 @@
**新機能:**
-- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (issue #381) (@Shirofune-Security)
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 99e9d483..fc7e05b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,7 +54,7 @@
**New Features:**
-- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (issue #381) (@Shirofune-Security)
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 68fa9ae3..6146fd3f 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -55,7 +55,7 @@
**新機能:**
-- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (issue #381) (@Shirofune-Security)
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 0c942e93..f4f3f065 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -57,7 +57,7 @@
**New Features:**
-- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (issue #381) (@Shirofune-Security)
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)
From 1bf6bc26b3bff71515d9bd2507d66737f8343c32 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:36:29 +0900
Subject: [PATCH 05/13] Add opt-in native WEF channel settings and preserved
CAPI2 read access
---
.github/workflows/native-channel-access.yml | 25 +++
CHANGELOG-Japanese.md | 2 +
CHANGELOG.md | 2 +
WELA.ps1 | 35 +++-
config/native_channel_profile.json | 206 ++++++++++++++++++++
docs/native-channel-access.md | 46 +++++
modules/NativeChannelAccess.psm1 | 120 ++++++++++++
scripts/Configuration.ps1 | 2 +-
scripts/NativeChannelConfiguration.ps1 | 142 ++++++++++++++
tests/NativeChannelAccess.Tests.ps1 | 159 +++++++++++++++
tests/NativeChannelAccess.Windows.Tests.ps1 | 86 ++++++++
website/docs/resources/changelog.ja.md | 2 +
website/docs/resources/changelog.md | 2 +
13 files changed, 826 insertions(+), 3 deletions(-)
create mode 100644 .github/workflows/native-channel-access.yml
create mode 100644 config/native_channel_profile.json
create mode 100644 docs/native-channel-access.md
create mode 100644 modules/NativeChannelAccess.psm1
create mode 100644 scripts/NativeChannelConfiguration.ps1
create mode 100644 tests/NativeChannelAccess.Tests.ps1
create mode 100644 tests/NativeChannelAccess.Windows.Tests.ps1
diff --git a/.github/workflows/native-channel-access.yml b/.github/workflows/native-channel-access.yml
new file mode 100644
index 00000000..7404a681
--- /dev/null
+++ b/.github/workflows/native-channel-access.yml
@@ -0,0 +1,25 @@
+name: Native channel access regressions
+on:
+ push:
+ branches: ['**']
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ native-channel-access:
+ runs-on: windows-latest
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Safe command and runner fixtures in Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/NativeChannelAccess.Tests.ps1
+ - name: Safe command and runner fixtures in PowerShell 7
+ shell: pwsh
+ run: ./tests/NativeChannelAccess.Tests.ps1
+ - name: Real descriptor and read-only CLI smoke in Windows PowerShell 5.1
+ shell: powershell
+ run: ./tests/NativeChannelAccess.Windows.Tests.ps1
+ - name: Real descriptor and read-only CLI smoke in PowerShell 7
+ shell: pwsh
+ run: ./tests/NativeChannelAccess.Windows.Tests.ps1
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index c79f4a86..f896b363 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,8 @@
**改善:**
+- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security)
+
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ab71489f..0e5145e2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,8 @@
**Improvements:**
+- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security)
+
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index c8196e0a..10f7977f 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -22,6 +22,10 @@
[ValidateRange(16384, 32767)][int]$FirewallMinimumSizeKiB = 16384,
[string]$HtmlPath,
[ValidateSet('Audit', 'Plan', 'Configure')][string]$SmbAction = 'Audit',
+ [ValidateSet('Audit', 'Plan', 'Configure')][string]$ChannelAction = 'Audit',
+ [string]$ChannelProfile = 'microsoft-wef-appendix-c',
+ [ValidateSet('Baseline', 'Suspect', 'Both')][string]$WefQuerySet = 'Both',
+ [switch]$GrantEventLogReaders,
[switch]$Help
)
@@ -42,6 +46,8 @@ Import-Module (Join-Path $ScriptRoot "modules/AuditProfiles.psm1") -ErrorAction
Import-Module (Join-Path $ScriptRoot "modules/NativeProviders.psm1") -ErrorAction Stop
Import-Module (Join-Path $ScriptRoot "modules/EventLogSettings.psm1") -ErrorAction Stop
. (Join-Path $ScriptRoot "scripts/EventLogConfiguration.ps1")
+Import-Module (Join-Path $ScriptRoot "modules/NativeChannelAccess.psm1") -ErrorAction Stop
+. (Join-Path $ScriptRoot "scripts/NativeChannelConfiguration.ps1")
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
$PowerShellPolicyRoots = @(
@@ -1666,6 +1672,10 @@ function Get-WelaUserProfiles {
$usage = @"
Usage:
+ ./WELA.ps1 channel-settings -ChannelAction Audit -WefQuerySet Both -ResultsPath channels.json
+ ./WELA.ps1 channel-settings -ChannelAction Plan -GrantEventLogReaders
+ ./WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -DryRun
+ # Native channels only; ACL changes require -GrantEventLogReaders. Forwarding identity access needs a separate test.
./WELA.ps1 firewall-logging -FirewallAction Audit -ResultsPath firewall.json
./WELA.ps1 firewall-logging -FirewallAction Plan -FirewallPathMode CisV4
./WELA.ps1 firewall-logging -FirewallAction Configure -DryRun
@@ -1705,8 +1715,9 @@ Write-Host ""
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($DryRun -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
- -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure')) {
- throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure and smb-auditing -SmbAction Configure. No command was run."
+ -not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
+ -not ($Cmd -eq 'channel-settings' -and $ChannelAction -eq 'Configure')) {
+ throw "-DryRun is supported only by configure (including configure -Profile), configure-eventlogs, firewall-logging -FirewallAction Configure, smb-auditing -SmbAction Configure and channel-settings -ChannelAction Configure. No command was run."
}
if ($Profile -and $Cmd -in @('eventlog-profiles', 'audit-filesize', 'configure-eventlogs')) {
throw '-Profile selects advanced audit policy only. Use -LogProfile for event-log size/mode settings.'
@@ -1718,12 +1729,32 @@ if (($ResizeLogs -or $ApplyLogMode) -and $Cmd -ne 'configure-eventlogs') {
throw '-ResizeLogs and -ApplyLogMode require configure-eventlogs. No command was run.'
}
+if (($PSBoundParameters.ContainsKey('ChannelAction') -or $PSBoundParameters.ContainsKey('ChannelProfile') -or
+ $PSBoundParameters.ContainsKey('WefQuerySet') -or $GrantEventLogReaders) -and $Cmd -ne 'channel-settings') {
+ throw 'Channel options require channel-settings. 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()) {
+ 'channel-settings' {
+ if ($Help) {
+ Write-Host 'Usage: ./WELA.ps1 channel-settings [-ChannelAction Audit|Plan|Configure] [-ChannelProfile microsoft-wef-appendix-c] [-WefQuerySet Baseline|Suspect|Both] [-GrantEventLogReaders] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
+ Write-Host 'Audits CAPI2/native WEF prerequisites. Configure enables/grows declared channels; only -GrantEventLogReaders permits adding the CAPI2 read ACE. Existing descriptor entries and retention are preserved. See docs/native-channel-access.md.'
+ return
+ }
+ if ($Profile -or $Baseline) { throw 'channel-settings uses -ChannelProfile; -Profile and -Baseline select Security audit settings.' }
+ if ($HtmlPath) { throw 'channel-settings exports JSON through -ResultsPath; -HtmlPath is not supported.' }
+ if ($ChannelAction -eq 'Configure' -and -not (TestAdministrator)) { throw 'channel-settings Configure requires Administrator privileges.' }
+ try {
+ $report = Invoke-WelaNativeChannelCommand -Action $ChannelAction -Profile $ChannelProfile -QuerySet $WefQuerySet -GrantEventLogReaders:$GrantEventLogReaders -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath -ResultsPath $ResultsPath
+ $report
+ if ($report.ExitCode) { exit $report.ExitCode }
+ } catch { Write-Host "[Failed] Native channel settings: $_" -ForegroundColor Red; exit 1 }
+ }
'firewall-logging' {
if ($Help) {
Write-Host 'Usage: ./WELA.ps1 firewall-logging [-FirewallAction Audit|Plan|Configure] [-FirewallPathMode Preserve|CisV4] [-FirewallMinimumSizeKiB 16384..32767] [-Auto] [-DryRun] [-BackupPath new-directory] [-ResultsPath file.json]'
diff --git a/config/native_channel_profile.json b/config/native_channel_profile.json
new file mode 100644
index 00000000..8dcee5d2
--- /dev/null
+++ b/config/native_channel_profile.json
@@ -0,0 +1,206 @@
+{
+ "schemaVersion": 1,
+ "id": "microsoft-wef-appendix-c",
+ "scope": "native-channel-settings-only",
+ "source": "https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection",
+ "reviewed": "2026-09-19",
+ "controls": [
+ {
+ "channel": "Microsoft-Windows-CAPI2/Operational",
+ "enabled": true,
+ "sourceExampleBytes": 102432768,
+ "readerSid": "S-1-5-32-573",
+ "readerMask": 1
+ },
+ {
+ "channel": "Microsoft-Windows-AppLocker/EXE and DLL",
+ "enabled": null,
+ "sourceExampleBytes": 102432768,
+ "readerSid": null,
+ "readerMask": null
+ },
+ {
+ "channel": "Microsoft-Windows-DriverFrameworks-UserMode/Operational",
+ "enabled": true,
+ "sourceExampleBytes": 52432896,
+ "readerSid": null,
+ "readerMask": null
+ }
+ ],
+ "querySets": {
+ "Baseline": {
+ "channels": [
+ {
+ "name": "Application",
+ "queryIds": [
+ "15",
+ "37",
+ "40"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-AppLocker/EXE and DLL",
+ "queryIds": [
+ "1"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-AppLocker/MSI and Script",
+ "queryIds": [
+ "1"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-AppLocker/Packaged app-Deployment",
+ "queryIds": [
+ "11"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-AppLocker/Packaged app-Execution",
+ "queryIds": [
+ "10"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-SMBClient/Operational",
+ "queryIds": [
+ "36"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-SmartCard-Audit/Authentication",
+ "queryIds": [
+ "35"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-TaskScheduler/Operational",
+ "queryIds": [
+ "3"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-TerminalServices-RDPClient/Operational",
+ "queryIds": [
+ "31"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-Windows Defender/Operational",
+ "queryIds": [
+ "41"
+ ]
+ },
+ {
+ "name": "Security",
+ "queryIds": [
+ "2",
+ "5",
+ "6",
+ "7",
+ "8",
+ "14",
+ "16",
+ "18",
+ "19",
+ "20",
+ "21",
+ "22",
+ "23",
+ "26",
+ "27",
+ "28",
+ "29",
+ "30",
+ "32",
+ "34",
+ "42"
+ ]
+ },
+ {
+ "name": "System",
+ "queryIds": [
+ "0",
+ "3",
+ "4",
+ "5",
+ "9",
+ "13",
+ "17"
+ ]
+ }
+ ],
+ "excludedQueries": [
+ {
+ "queryId": "12",
+ "reason": "EMET is not built in"
+ },
+ {
+ "queryId": "39",
+ "reason": "Sysmon is out of scope"
+ }
+ ]
+ },
+ "Suspect": {
+ "channels": [
+ {
+ "name": "Microsoft-Windows-CAPI2/Operational",
+ "queryIds": [
+ "2"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-DNS-Client/Operational",
+ "queryIds": [
+ "7"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-DriverFrameworks-UserMode/Operational",
+ "queryIds": [
+ "13"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-LSA/Operational",
+ "queryIds": [
+ "4"
+ ]
+ },
+ {
+ "name": "Microsoft-Windows-PowerShell/Operational",
+ "queryIds": [
+ "12"
+ ]
+ },
+ {
+ "name": "Security",
+ "queryIds": [
+ "0",
+ "3",
+ "5",
+ "6",
+ "8",
+ "9",
+ "10",
+ "11"
+ ]
+ },
+ {
+ "name": "System",
+ "queryIds": [
+ "1"
+ ]
+ },
+ {
+ "name": "Windows PowerShell",
+ "queryIds": [
+ "14"
+ ]
+ }
+ ],
+ "excludedQueries": []
+ }
+ }
+}
diff --git a/docs/native-channel-access.md b/docs/native-channel-access.md
new file mode 100644
index 00000000..2a86baa1
--- /dev/null
+++ b/docs/native-channel-access.md
@@ -0,0 +1,46 @@
+# Native channel settings and CAPI2 access
+
+`channel-settings` audits, plans and optionally applies the native channel examples in Microsoft's WEF Appendix C. Its separate query inventory identifies the channels required by the selected Appendix E/F queries. This is an opt-in command; ordinary `configure` does not change channel ACLs.
+
+```powershell
+.\WELA.ps1 channel-settings -ChannelAction Audit -WefQuerySet Baseline -ResultsPath channels.json
+.\WELA.ps1 channel-settings -ChannelAction Plan -WefQuerySet Both -GrantEventLogReaders -ResultsPath plan.json
+.\WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -DryRun -ResultsPath preview.json
+# Elevated Windows shell, after reviewing the plan; prompts unless -Auto is supplied:
+.\WELA.ps1 channel-settings -ChannelAction Configure -GrantEventLogReaders -BackupPath C:\WELA-Recovery\channels-run1 -ResultsPath result.json
+```
+
+The named `-ChannelProfile microsoft-wef-appendix-c` is the only profile. `-WefQuerySet Baseline|Suspect|Both` selects **inventory**, not which Appendix C controls are applied. Audit and Plan never modify Windows. Configure always requests the three declared enable/size controls; adding the CAPI2 reader ACE additionally requires `-GrantEventLogReaders`. Without it, the existing descriptor is preserved and a missing read grant remains an unmet prerequisite. JSON exports include the full current/proposed descriptor, source bytes, native read failures, query IDs and unverified prerequisites. Access failures stay unknown; unregistered channels stay not installed and require role/query review.
+
+| Channel | Enabled setting | Source example bytes | Rounded minimum applied | Access request |
+|---|---|---:|---:|---|
+| Microsoft-Windows-CAPI2/Operational | Enable | 102432768 | 102432768 | Event Log Readers read, explicit opt-in |
+| Microsoft-Windows-AppLocker/EXE and DLL | Preserve | 102432768 | 102432768 | Preserve |
+| Microsoft-Windows-DriverFrameworks-UserMode/Operational | Enable | 52432896 | 52494336 | Preserve |
+
+The source examples are **not** 100 MiB and 50 MiB. Larger existing limits and retention modes are preserved. These are channel buffer examples, not promised retention duration. Source: [Microsoft WEF Appendix C](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection#appendix-c---event-channel-settings-enable-and-channel-access-methods). Applied limits round upward to a 64 KiB unit as required by [wevtutil](https://learn.microsoft.com/en-us/windows-server/administration/windows-commands/wevtutil).
+
+## Permission and mutation boundaries
+
+The appended ACE grants SID `S-1-5-32-573` (Event Log Readers) **read only**, access mask `0x1`. Existing ACEs are not broadened or removed; even an existing write-only grant is retained and a separate read ACE is appended. WELA does not copy Microsoft's complete example descriptor over the host descriptor. Event Log read, write and clear are separate [Windows access constants](https://learn.microsoft.com/en-us/windows/win32/wes/windows-event-log-constants).
+
+The planner uses [RawSecurityDescriptor](https://learn.microsoft.com/en-us/dotnet/api/system.security.accesscontrol.rawsecuritydescriptor?view=netframework-4.8.1), clones its binary representation, inserts an explicit allow before the first inherited ACE and verifies an exact binary round trip through the proposed SDDL. Owner, group, SACL, control flags and every existing ACE byte/order must survive. No ACL canonicalization occurs. Absent/null DACLs, any applicable read-deny ACE, unknown ACEs and descriptors that cannot round-trip losslessly require manual review and are left unchanged. Recognized object/callback ACEs are retained only if lossless serialization succeeds. This conservative rule may decline descriptors that an administrator can safely edit manually.
+
+`GrantPresent` describes an unconditional group read ACE in the descriptor. It **does not establish effective read access** for any user or service token. Group membership, denied groups, privileges, actual event reads and forwarding remain separate. Read permission also does not establish AppLocker policy, provider generation readiness, or Sigma rule usability.
+
+The shared configuration runner writes `before.jsonl` before each native mutation, capturing the original enabled state, exact size, full descriptor and retention mode. A fresh read must match both the plan and the journal snapshot before `wevtutil sl` executes. Only changed `/e:true`, `/ms:...` and explicitly authorized `/ca:...` arguments are sent. Native failure, failed readback, descriptor mismatch and final drift produce a nonzero result. There is no atomic Windows compare-and-set; another writer can still race the final check. Re-run after policy refresh to check persistence. No automatic rollback occurs.
+
+Recovery is manual: review each journal `Before` against the current settings, identify the affected channel, and restore only the intended previous values using `wevtutil sl "CHANNEL" /e:true|false /ms:ORIGINAL_BYTES /ca:"ORIGINAL_SDDL"`. Pass the descriptor as one argument in PowerShell, for example `& wevtutil.exe sl $entry.Target.Channel ("/ca:" + $entry.Before.SecurityDescriptor)` after loading and reviewing the relevant JSONL entry. Restoring a smaller limit can discard events. Existing retention is not intentionally modified; investigate any changed mode before choosing recovery actions. Use a new backup directory for each run.
+
+## Native WEF prerequisites and validation
+
+The checked-in inventory maps source query IDs to 12 baseline channels and 8 suspect channels (18 unique combined). Baseline queries 12 (EMET) and 39 (Sysmon) are explicitly excluded. Native-only scope excludes external agents; channel settings do not create subscriptions, add service identities to groups or configure WinRM/collectors. The Microsoft [source prerequisite guidance and sample queries](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) remain a starting point for reviewing role applicability. The report always lists token/group membership, producer configuration, representative event generation and forwarding/ingestion as unverified; required disabled or missing channels remain visible. Other inventoried channels are not automatically enabled.
+
+Safe tests exercise the actual command/JSON/runner with mocked Windows setters. Windows PowerShell 5.1 and PowerShell 7 CI additionally exercise real descriptor serialization and read-only CLI inspection. Release packaging already includes the whole `config`, `modules` and `scripts` directories.
+
+**Isolated Windows acceptance evidence is still pending; related to issue #367, not sufficient to close it.** On patched Windows 11, member server, DC and ADCS snapshots where the channels exist:
+
+1. Save the plan, channel metadata, descriptor and policy context. Review capacity and the intended forwarding identity. Capture the actual identity/token memberships separately.
+2. Apply the opt-in profile, retain the journal/results, then independently read enablement, exact bytes and full SDDL. Compare all original ACEs plus owner/group/SACL/flags and repeat after policy refresh.
+3. Using the intended forwarding identity's actual token, read CAPI2 event records. An administrator's successful query or a matching group ACE is insufficient evidence. Record denied/missing cases explicitly.
+4. Generate a benign native event appropriate to the isolated role, retain its XML and verify matching collector ingestion under the intended subscription. WELA does not perform this test or claim any measured Sigma coverage increase.
diff --git a/modules/NativeChannelAccess.psm1 b/modules/NativeChannelAccess.psm1
new file mode 100644
index 00000000..2e1942c4
--- /dev/null
+++ b/modules/NativeChannelAccess.psm1
@@ -0,0 +1,120 @@
+# Native channel metadata and lossless, read-only ACL planning. Windows PowerShell 5.1.
+function Get-WelaNativeChannelProfile {
+ param([string]$Id = 'microsoft-wef-appendix-c')
+ $profile = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../config/native_channel_profile.json') -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
+ if ($Id -ne $profile.id -or $profile.schemaVersion -ne 1 -or $profile.scope -ne 'native-channel-settings-only') { throw "Unknown/invalid native channel profile '$Id'." }
+ $names = @{}
+ foreach ($control in $profile.controls) {
+ if (-not $control.channel -or $control.channel -match '[*?\[\]\r\n]' -or $names.ContainsKey($control.channel)) { throw 'Invalid/duplicate native channel name.' }
+ $names[$control.channel] = $true
+ if ($null -ne $control.enabled -and ($control.enabled -isnot [bool] -or -not $control.enabled)) { throw 'Native channel profiles may only enable a channel or preserve its enabled state.' }
+ if ($control.sourceExampleBytes -isnot [int] -and $control.sourceExampleBytes -isnot [long]) { throw 'Channel size must be an integer byte count.' }
+ $null = ConvertTo-WelaEventLogBytes $control.sourceExampleBytes
+ if ($control.readerSid -and ($control.readerSid -ne 'S-1-5-32-573' -or $control.readerMask -ne 1)) { throw 'Only the Event Log Readers read grant is supported.' }
+ }
+ foreach ($name in @('Baseline', 'Suspect')) {
+ if (@($profile.querySets.$name.channels).Count -eq 0) { throw "Empty native WEF query inventory: $name" }
+ foreach ($channel in $profile.querySets.$name.channels) {
+ if (-not $channel.name -or $channel.name -match '[*?\[\]\r\n]|Sysmon' -or @($channel.queryIds).Count -eq 0) { throw 'Invalid native WEF query inventory.' }
+ }
+ }
+ return $profile
+}
+
+function Get-WelaDescriptorBytes {
+ param($Descriptor)
+ $bytes = New-Object byte[] $Descriptor.BinaryLength
+ $Descriptor.GetBinaryForm($bytes, 0)
+ return ,$bytes
+}
+
+function Test-WelaChannelDescriptorEqual {
+ param([string]$First, [string]$Second)
+ if (-not $First -or -not $Second) { return $false }
+ try {
+ $a = [System.Security.AccessControl.RawSecurityDescriptor]::new($First)
+ $b = [System.Security.AccessControl.RawSecurityDescriptor]::new($Second)
+ return [Convert]::ToBase64String((Get-WelaDescriptorBytes $a)) -ceq [Convert]::ToBase64String((Get-WelaDescriptorBytes $b))
+ } catch { return $false }
+}
+
+function Get-WelaChannelAccessPlan {
+ param([string]$SecurityDescriptor)
+ $result = [ordered]@{
+ State = 'Unknown'; Sid = 'S-1-5-32-573'; AccessMask = 1
+ ProposedDescriptor = $null; ExistingAceCount = $null; AddedAceIndex = $null
+ EffectiveReadAccess = 'Not tested'; Diagnostic = ''
+ }
+ try {
+ if (-not $SecurityDescriptor) { throw 'Channel security descriptor was not readable.' }
+ $original = [System.Security.AccessControl.RawSecurityDescriptor]::new($SecurityDescriptor)
+ if (-not ($original.ControlFlags -band [System.Security.AccessControl.ControlFlags]::DiscretionaryAclPresent) -or $null -eq $original.DiscretionaryAcl) {
+ throw 'Absent/null DACL requires manual review; adding a DACL would change unrelated access.'
+ }
+ $result.ExistingAceCount = $original.DiscretionaryAcl.Count
+ $grant = $false; $deny = $false; $unknownAce = $false
+ foreach ($ace in $original.DiscretionaryAcl) {
+ if ($ace -isnot [System.Security.AccessControl.KnownAce]) { $unknownAce = $true; continue }
+ if ($ace.AceFlags -band [System.Security.AccessControl.AceFlags]::InheritOnly) { continue }
+ $readMask = ($ace.AccessMask -band 1) -or ($ace.AccessMask -band 268435456) -or ($ace.AccessMask -band [int]::MinValue)
+ if ($readMask -and $ace.AceQualifier -eq [System.Security.AccessControl.AceQualifier]::AccessDenied) { $deny = $true }
+ if ($ace -is [System.Security.AccessControl.CommonAce] -and -not $ace.IsCallback -and
+ $ace.AceQualifier -eq [System.Security.AccessControl.AceQualifier]::AccessAllowed -and
+ $ace.SecurityIdentifier.Value -eq $result.Sid -and ($ace.AccessMask -band 1)) { $grant = $true }
+ }
+ if ($unknownAce) { throw 'An unknown ACE requires manual review; the descriptor is preserved without mutation.' }
+ if ($deny) { throw 'A read-deny ACE may affect the forwarding token; the descriptor is preserved for manual review.' }
+ if ($grant) { $result.State = 'GrantPresent'; return [pscustomobject]$result }
+ $copy = [System.Security.AccessControl.RawSecurityDescriptor]::new((Get-WelaDescriptorBytes $original), 0)
+ $newAce = [System.Security.AccessControl.CommonAce]::new(
+ [System.Security.AccessControl.AceFlags]::None,
+ [System.Security.AccessControl.AceQualifier]::AccessAllowed, 1,
+ [System.Security.Principal.SecurityIdentifier]::new($result.Sid), $false, $null)
+ # Retain every existing ACE, including callback/object ACEs, in original order.
+ # Place the explicit allow before inherited entries; do not canonicalize others.
+ $index = $copy.DiscretionaryAcl.Count
+ for ($i = 0; $i -lt $copy.DiscretionaryAcl.Count; $i++) {
+ if ($copy.DiscretionaryAcl[$i].AceFlags -band [System.Security.AccessControl.AceFlags]::Inherited) { $index = $i; break }
+ }
+ $copy.DiscretionaryAcl.InsertAce($index, $newAce)
+ $sddl = $copy.GetSddlForm([System.Security.AccessControl.AccessControlSections]::All)
+ $roundTrip = [System.Security.AccessControl.RawSecurityDescriptor]::new($sddl)
+ if ([Convert]::ToBase64String((Get-WelaDescriptorBytes $copy)) -cne [Convert]::ToBase64String((Get-WelaDescriptorBytes $roundTrip))) {
+ throw 'SDDL conversion was not lossless; refusing to replace the channel descriptor.'
+ }
+ $result.State = 'GrantRequired'; $result.ProposedDescriptor = $sddl; $result.AddedAceIndex = $index
+ } catch {
+ $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message
+ }
+ [pscustomobject]$result
+}
+
+function Get-WelaNativeChannelInventory {
+ param($Profile, [ValidateSet('Baseline', 'Suspect', 'Both')][string]$QuerySet = 'Both')
+ $selected = if ($QuerySet -eq 'Both') { @('Baseline', 'Suspect') } else { @($QuerySet) }
+ $entries = @{}
+ foreach ($set in $selected) {
+ foreach ($channel in $Profile.querySets.$set.channels) {
+ if (-not $entries.ContainsKey($channel.name)) { $entries[$channel.name] = @() }
+ $entries[$channel.name] += [pscustomobject]@{ QuerySet = $set; QueryIds = @($channel.queryIds) }
+ }
+ }
+ foreach ($name in @($entries.Keys | Sort-Object)) {
+ $channel = Get-WelaNativeChannel -Name $name
+ $unmet = @()
+ if ($channel.State -eq 'Not installed') { $unmet += 'Channel not installed; review role/query applicability.' }
+ elseif ($channel.State -ne 'Enabled') { $unmet += "Channel enabled state is $($channel.State)." }
+ if (-not $channel.SecurityDescriptor) { $unmet += 'Channel security descriptor unreadable.' }
+ # A channel ACE does not establish group membership, token access, producer
+ # configuration or WEF ingestion. No usable-rule credit is derived here.
+ $unmet += @('Event producer/audit policy and representative event generation not verified.',
+ 'Intended forwarding identity token and actual event read not tested.',
+ 'WEF subscription/transport and collector ingestion not verified.')
+ [pscustomobject]@{
+ Channel = $channel; Queries = @($entries[$name]); Prerequisites = $unmet
+ EffectiveReadAccess = 'Not tested'; EventGeneration = 'Not tested'; Forwarding = 'Not tested'
+ }
+ }
+}
+
+Export-ModuleMember -Function Get-WelaNativeChannelProfile, Test-WelaChannelDescriptorEqual, Get-WelaChannelAccessPlan, Get-WelaNativeChannelInventory
diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1
index b0fe1121..bd200c68 100644
--- a/scripts/Configuration.ps1
+++ b/scripts/Configuration.ps1
@@ -94,7 +94,7 @@ function Invoke-WelaConfigurationControl {
function Complete-WelaConfiguration {
param($Context, [string]$ResultsPath, $Plan,
- [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only")]
+ [ValidateSet("native-windows-configuration", "advanced-audit-policy-only", "advanced-audit-policy-and-precedence", "firewall-text-logging-only", "event-log-size-and-mode-only", "smb-audit-policies-only", "native-channel-settings-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
# A second read detects a value that was compliant earlier but changed during
diff --git a/scripts/NativeChannelConfiguration.ps1 b/scripts/NativeChannelConfiguration.ps1
new file mode 100644
index 00000000..ef10f8b7
--- /dev/null
+++ b/scripts/NativeChannelConfiguration.ps1
@@ -0,0 +1,142 @@
+# Uses the shared configuration runner; no live writes occur in Audit or Plan.
+function Test-WelaNativeChannelSnapshot {
+ param($Snapshot)
+ return $Snapshot.State -in @('Enabled', 'Disabled') -and $Snapshot.IsEnabled -is [bool] -and
+ $null -ne $Snapshot.MaximumSizeInBytes -and $Snapshot.MaximumSizeInBytes -gt 0 -and
+ $Snapshot.LogMode -in @('Circular', 'AutoBackup', 'Retain') -and
+ -not [string]::IsNullOrWhiteSpace($Snapshot.SecurityDescriptor)
+}
+
+function Test-WelaNativeChannelSnapshotEqual {
+ param($First, $Second)
+ if (-not (Test-WelaNativeChannelSnapshot $First) -or -not (Test-WelaNativeChannelSnapshot $Second)) { return $false }
+ return $First.Name -eq $Second.Name -and $First.IsEnabled -eq $Second.IsEnabled -and
+ $First.MaximumSizeInBytes -eq $Second.MaximumSizeInBytes -and $First.LogMode -eq $Second.LogMode -and
+ (Test-WelaChannelDescriptorEqual $First.SecurityDescriptor $Second.SecurityDescriptor)
+}
+
+function Get-WelaNativeChannelPlan {
+ param($Profile, [switch]$GrantEventLogReaders)
+ foreach ($control in $Profile.controls) {
+ $before = Get-WelaNativeChannel -Name $control.channel
+ $access = if ($control.readerSid) { Get-WelaChannelAccessPlan -SecurityDescriptor $before.SecurityDescriptor } else { $null }
+ $minimum = ConvertTo-WelaEventLogBytes $control.sourceExampleBytes
+ $valid = Test-WelaNativeChannelSnapshot $before
+ $desiredAcl = $before.SecurityDescriptor
+ if ($GrantEventLogReaders -and $control.readerSid -and $access.State -eq 'GrantRequired') { $desiredAcl = $access.ProposedDescriptor }
+ $status = if (-not $valid) { if ($before.State -eq 'Not installed') { 'NotInstalled' } else { 'Unknown' } }
+ elseif ($GrantEventLogReaders -and $access -and $access.State -notin @('GrantPresent', 'GrantRequired')) { 'ManualReview' }
+ elseif (($null -ne $control.enabled -and $before.IsEnabled -ne $control.enabled) -or $before.MaximumSizeInBytes -lt $minimum -or
+ ($GrantEventLogReaders -and $access -and $access.State -eq 'GrantRequired')) { 'ChangeRequired' } else { 'RequestedSettingsMatch' }
+ [pscustomobject][ordered]@{
+ Definition = $control; Before = $before; Status = $status; Access = $access
+ Desired = [pscustomobject]@{
+ IsEnabled = $(if ($null -eq $control.enabled) { $before.IsEnabled } else { $control.enabled })
+ SourceExampleBytes = [long]$control.sourceExampleBytes; RoundedMinimumBytes = $minimum
+ MaximumSizeInBytes = $(if ($valid) { [math]::Max([long]$before.MaximumSizeInBytes, $minimum) } else { $null })
+ LogMode = $before.LogMode; SecurityDescriptor = $desiredAcl
+ AccessChangeRequested = [bool]($GrantEventLogReaders -and $control.readerSid)
+ }
+ Prerequisites = @($(if ($access -and $access.State -ne 'GrantPresent') { "Event Log Readers read ACE: $($access.State). Use -GrantEventLogReaders only after reviewing the proposed descriptor; manual-review states cannot be changed automatically." }),
+ 'Effective forwarding identity read access and actual event/forwarding evidence remain unverified.') | Where-Object { $_ }
+ }
+ }
+}
+
+function Set-WelaNativeChannelControls {
+ param($Context, [array]$Plan, [string]$Profile)
+ foreach ($entry in $Plan) {
+ $channel = $entry.Definition.channel
+ $id = "NativeChannel/$channel/Settings"
+ if ($entry.Status -in @('NotInstalled', 'Unknown', 'ManualReview')) {
+ $Context.Results.Add([pscustomobject]@{
+ Id = $id; Kind = 'NativeChannel'; Target = @{ Channel = $channel; Profile = $Profile }
+ Desired = $entry.Desired; Before = $entry.Before; After = $null; Status = 'Failed'
+ Diagnostic = "$($entry.Status): channel metadata/ACL cannot safely be configured. $($entry.Access.Diagnostic)"
+ })
+ continue
+ }
+ $state = @{ Entry = $entry; InitialRead = $true; Snapshot = $null }
+ $read = {
+ param($state)
+ $current = Get-WelaNativeChannel -Name $state.Entry.Definition.channel
+ if (-not (Test-WelaNativeChannelSnapshot $current)) { throw 'Channel settings became unreadable; no assumed defaults are used.' }
+ if ($state.InitialRead) {
+ # The plan may outlive another writer. Never apply an ACL based on
+ # an old descriptor, even before the shared runner's first read.
+ if (-not (Test-WelaNativeChannelSnapshotEqual $state.Entry.Before $current)) { throw 'Channel settings changed after planning; review a fresh plan before retrying.' }
+ $state.Snapshot = $current; $state.InitialRead = $false
+ }
+ return $current
+ }
+ $test = {
+ param($current, $state)
+ $desired = $state.Entry.Desired
+ return $current.IsEnabled -eq $desired.IsEnabled -and $current.MaximumSizeInBytes -eq $desired.MaximumSizeInBytes -and
+ $current.LogMode -eq $desired.LogMode -and (Test-WelaChannelDescriptorEqual $current.SecurityDescriptor $desired.SecurityDescriptor)
+ }
+ $apply = {
+ param($state)
+ $entry = $state.Entry
+ $fresh = Get-WelaNativeChannel -Name $entry.Definition.channel
+ if (-not (Test-WelaNativeChannelSnapshotEqual $state.Snapshot $fresh)) { throw 'Channel settings changed after the recovery snapshot; no channel write was attempted.' }
+ $arguments = @('sl', $entry.Definition.channel)
+ if ($fresh.IsEnabled -ne $entry.Desired.IsEnabled) { $arguments += '/e:true' }
+ if ($fresh.MaximumSizeInBytes -ne $entry.Desired.MaximumSizeInBytes) { $arguments += "/ms:$($entry.Desired.MaximumSizeInBytes)" }
+ if (-not (Test-WelaChannelDescriptorEqual $fresh.SecurityDescriptor $entry.Desired.SecurityDescriptor)) {
+ if (-not $entry.Desired.AccessChangeRequested -or $entry.Access.State -ne 'GrantRequired') { throw 'An ACL difference has no explicit, validated read-grant request.' }
+ $arguments += "/ca:$($entry.Desired.SecurityDescriptor)"
+ }
+ if ($arguments.Count -gt 2) { Invoke-WelaNative -FilePath 'wevtutil.exe' -Arguments $arguments }
+ }
+ Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind NativeChannel -Target @{ Channel = $channel; Profile = $Profile } `
+ -Desired $entry.Desired -Read $read -Compliant $test -Apply $apply -CallbackState $state `
+ -Description "Apply declared enable/minimum-size settings; preserve larger buffers, retention and existing ACEs. Add only the Event Log Readers read ACE when explicitly requested."
+ }
+}
+
+function Invoke-WelaNativeChannelCommand {
+ param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit',
+ [string]$Profile = 'microsoft-wef-appendix-c',
+ [ValidateSet('Baseline', 'Suspect', 'Both')][string]$QuerySet = 'Both',
+ [switch]$GrantEventLogReaders, [switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
+ if ($env:OS -ne 'Windows_NT') { throw 'Native channel settings require Windows.' }
+ if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun requires ChannelAction Configure; Audit and Plan are read-only.' }
+ $selected = Get-WelaNativeChannelProfile -Id $Profile
+ $plan = @(Get-WelaNativeChannelPlan -Profile $selected -GrantEventLogReaders:$GrantEventLogReaders)
+ if ($Action -eq 'Configure') {
+ $context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
+ Set-WelaNativeChannelControls -Context $context -Plan $plan -Profile $selected.id
+ $report = Complete-WelaConfiguration -Context $context -Scope 'native-channel-settings-only' `
+ -SuccessMessage 'Requested channel settings verified. Forwarding identity read access and event/ingestion evidence remain unverified.'
+ } else {
+ $report = [pscustomobject]@{ Scope = 'native-channel-settings-only'; ExitCode = $(if (@($plan | Where-Object Status -in @('Unknown', 'NotInstalled', 'ManualReview')).Count) { 1 } else { 0 }) }
+ }
+ # Read inventory after configuration so exports do not show only stale pre-state.
+ $inventory = @(Get-WelaNativeChannelInventory -Profile $selected -QuerySet $QuerySet)
+ $current = if ($Action -eq 'Configure') { @(Get-WelaNativeChannelPlan -Profile $selected -GrantEventLogReaders:$GrantEventLogReaders) } else { $plan }
+ $excluded = @()
+ foreach ($set in @('Baseline', 'Suspect')) {
+ if ($QuerySet -eq 'Both' -or $QuerySet -eq $set) {
+ foreach ($query in $selected.querySets.$set.excludedQueries) { $excluded += [pscustomobject]@{ QuerySet = $set; QueryId = $query.queryId; Reason = $query.reason } }
+ }
+ }
+ $report | Add-Member NoteProperty Action $Action
+ $report | Add-Member NoteProperty ChannelProfile $selected.id
+ $report | Add-Member NoteProperty Source $selected.source
+ $report | Add-Member NoteProperty WefQuerySet $QuerySet
+ $report | Add-Member NoteProperty GrantEventLogReadersRequested ([bool]$GrantEventLogReaders)
+ $report | Add-Member NoteProperty Controls $current
+ $report | Add-Member NoteProperty QueryInventory $inventory
+ $report | Add-Member NoteProperty ExcludedQueries $excluded
+ $report | Add-Member NoteProperty ForwardingReadiness 'Not verified'
+ $report | Add-Member NoteProperty UnverifiedPrerequisites @('Forwarding token/group membership (including Network Service where applicable)', 'WinRM and collector/subscription configuration', 'Representative native events, identity read access and collector ingestion')
+ Write-Host 'Native query inventory and channel settings are observations only. Forwarding access, event generation and ingestion are not verified; no Sigma coverage increase is claimed.' -ForegroundColor Yellow
+ $current | Select-Object @{n='Channel';e={$_.Definition.channel}}, Status, @{n='ReaderAce';e={$_.Access.State}}, @{n='SourceBytes';e={$_.Desired.SourceExampleBytes}}, @{n='MinimumBytes';e={$_.Desired.RoundedMinimumBytes}} | Format-Table -AutoSize | Out-Host
+ $inventory | Select-Object @{n='RequiredChannel';e={$_.Channel.Name}}, @{n='State';e={$_.Channel.State}}, EffectiveReadAccess | Format-Table -AutoSize | Out-Host
+ if ($ResultsPath) {
+ try { $report | ConvertTo-Json -Depth 16 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
+ catch { $report.ExitCode = 1; Write-Host "[Failed] Writing channel results: $_" -ForegroundColor Red }
+ }
+ return $report
+}
diff --git a/tests/NativeChannelAccess.Tests.ps1 b/tests/NativeChannelAccess.Tests.ps1
new file mode 100644
index 00000000..1788d7b7
--- /dev/null
+++ b/tests/NativeChannelAccess.Tests.ps1
@@ -0,0 +1,159 @@
+# Safe fixtures through the public command/report and shared runner. No Windows writes.
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/EventLogSettings.psm1') -Force
+Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
+Import-Module (Join-Path $repo 'modules/NativeChannelAccess.psm1') -Force
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/NativeChannelConfiguration.ps1')
+$script:ScriptRoot = $repo
+$script:assertions = 0
+$script:cleanup = New-Object 'System.Collections.Generic.List[string]'
+function Assert($Condition, [string]$Message) {
+ if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++
+}
+function New-FixtureState([string]$Name) {
+ [pscustomobject]@{ Name = $Name; State = 'Disabled'; IsEnabled = $false; LogMode = 'Retain'; SecurityDescriptor = 'fixture-original'; MaximumSizeInBytes = [long]1048576; MetadataErrors = @{}; Error = $null }
+}
+function Reset-Fixture {
+ $script:profile = Get-WelaNativeChannelProfile
+ $global:WelaChannelFixture = @{ States = @{}; Reads = @{}; Writes = (New-Object 'System.Collections.Generic.List[object]'); DriftRead = 0; Failure = ''; Prompt = 'Y' }
+ foreach ($control in $script:profile.controls) { $global:WelaChannelFixture.States[$control.channel] = New-FixtureState $control.channel }
+ $script:capi = $script:profile.controls[0].channel
+ $script:app = $script:profile.controls[1].channel
+ $script:driver = $script:profile.controls[2].channel
+ $script:backup = Join-Path ([IO.Path]::GetTempPath()) ('wela-channel-' + [guid]::NewGuid().ToString('N'))
+ $global:WelaChannelFixture.Backup = $script:backup
+ $script:cleanup.Add($script:backup)
+}
+function Get-WelaNativeChannel {
+ param($Name)
+ $f = $global:WelaChannelFixture
+ if (-not $f.States.ContainsKey($Name)) { return New-FixtureState $Name }
+ if (-not $f.Reads.ContainsKey($Name)) { $f.Reads[$Name] = 0 }
+ $f.Reads[$Name]++
+ if ($f.DriftRead -eq $f.Reads[$Name] -and $Name -eq $script:capi) { $f.States[$Name].SecurityDescriptor = 'fixture-concurrent' }
+ return $f.States[$Name].PSObject.Copy()
+}
+# Windows ACL serialization is tested separately against the real .NET APIs. These
+# token descriptors let the command/runner fail-path tests execute safely on Linux.
+function Test-WelaChannelDescriptorEqual { param($First, $Second) return $First -and $Second -and $First -ceq $Second }
+function Get-WelaChannelAccessPlan {
+ param($SecurityDescriptor)
+ [pscustomobject]@{
+ State = $(if ($SecurityDescriptor -eq 'fixture-granted') { 'GrantPresent' } elseif ($SecurityDescriptor -eq 'fixture-original') { 'GrantRequired' } else { 'ManualReview' })
+ ProposedDescriptor = 'fixture-granted'; EffectiveReadAccess = 'Not tested'; Diagnostic = 'Fixture ACL planner'
+ }
+}
+function Read-Host { param($Prompt) return $global:WelaChannelFixture.Prompt }
+function Invoke-WelaNative {
+ param($FilePath, $Arguments)
+ $f = $global:WelaChannelFixture
+ Assert ($FilePath -eq 'wevtutil.exe' -and $Arguments[0] -eq 'sl') 'Only wevtutil channel settings are written'
+ $journal = Join-Path $f.Backup 'before.jsonl'
+ Assert (Test-Path -LiteralPath $journal) 'Recovery journal exists before native write'
+ $record = @(Get-Content -LiteralPath $journal | ForEach-Object { $_ | ConvertFrom-Json })[-1]
+ Assert ($record.Target.Channel -eq $Arguments[1] -and $record.Before.SecurityDescriptor -eq $f.States[$Arguments[1]].SecurityDescriptor) 'Journal holds the fresh original descriptor for this channel'
+ $f.Writes.Add(@($Arguments))
+ if ($f.Failure -eq 'native') { throw 'fixture native failure' }
+ if ($f.Failure -eq 'false-success') { return }
+ foreach ($argument in $Arguments) {
+ if ($argument -eq '/e:true') { $f.States[$Arguments[1]].IsEnabled = $true; $f.States[$Arguments[1]].State = 'Enabled' }
+ if ($argument -like '/ms:*') { $f.States[$Arguments[1]].MaximumSizeInBytes = [long]$argument.Substring(4) }
+ if ($argument -like '/ca:*') { $f.States[$Arguments[1]].SecurityDescriptor = $argument.Substring(4) }
+ }
+}
+$module = Get-Module NativeChannelAccess
+& $module {
+ function script:Get-WelaNativeChannel {
+ param($Name)
+ if ($global:WelaChannelFixture.States.ContainsKey($Name)) { return $global:WelaChannelFixture.States[$Name].PSObject.Copy() }
+ [pscustomobject]@{ Name = $Name; State = 'Not installed'; IsEnabled = $null; LogMode = $null; SecurityDescriptor = $null; MaximumSizeInBytes = $null; MetadataErrors = @{}; Error = @{ Message = 'fixture missing registration' } }
+ }
+}
+$savedOS = $env:OS
+try {
+ $env:OS = 'Windows_NT' # Only mocked readers/setters are reachable in this suite.
+ Reset-Fixture
+ Assert ($script:profile.controls.Count -eq 3) 'Profile declares exactly the three Appendix C channel examples'
+ Assert ($script:profile.controls[0].sourceExampleBytes -eq 102432768 -and $script:profile.controls[1].sourceExampleBytes -eq 102432768) 'CAPI2/AppLocker preserve the exact source byte values'
+ Assert ($script:profile.controls[2].sourceExampleBytes -eq 52432896) 'DriverFrameworks source is not approximated as 50 MiB'
+ Assert ((ConvertTo-WelaEventLogBytes 52432896) -eq 52494336) 'Applied minimum rounds upward to Windows 64 KiB units'
+ $caught = $false; try { Get-WelaNativeChannelProfile -Id 'unknown' } catch { $caught = $true }
+ Assert $caught 'Unknown channel profile is rejected'
+ $out = $script:backup + '.json'; $script:cleanup.Add($out)
+ $report = Invoke-WelaNativeChannelCommand -Action Plan -GrantEventLogReaders -ResultsPath $out
+ $json = Get-Content -LiteralPath $out -Raw | ConvertFrom-Json
+ Assert ($json.Controls[0].Desired.AccessChangeRequested -and $json.Controls[0].Desired.SecurityDescriptor -eq 'fixture-granted') 'Public JSON contains explicit proposed CAPI2 ACL'
+ Assert ($json.QueryInventory.Count -eq 18 -and $json.ExcludedQueries.Count -eq 2) 'Both queries inventory 18 unique native channels and exclude EMET/Sysmon'
+ Assert (@($json.QueryInventory | Where-Object { $_.Channel.Name -like '*Sysmon*' }).Count -eq 0) 'Sysmon is outside native inventory'
+ Assert (($json.QueryInventory | Where-Object { $_.Channel.Name -eq 'Microsoft-Windows-CAPI2/Operational' }).Queries[0].QueryIds[0] -eq '2') 'Inventory preserves source query IDs'
+ Assert ($json.ForwardingReadiness -eq 'Not verified' -and @($json.QueryInventory | Where-Object EffectiveReadAccess -ne 'Not tested').Count -eq 0) 'Public export does not infer identity access or forwarding from ACEs'
+ Assert (($json.QueryInventory | Where-Object { $_.Channel.Name -eq 'Security' }).Channel.State -eq 'Not installed') 'Inventory retains absent channel evidence'
+ Assert ($global:WelaChannelFixture.Writes.Count -eq 0 -and -not (Test-Path $script:backup)) 'Plan performs no mutation or journal creation'
+ $report = Invoke-WelaNativeChannelCommand -Action Audit -QuerySet Baseline
+ Assert ($report.QueryInventory.Count -eq 12) 'Baseline query selection inventories its twelve native channels'
+ $report = Invoke-WelaNativeChannelCommand -Action Audit -QuerySet Suspect
+ Assert ($report.QueryInventory.Count -eq 8 -and $report.ExcludedQueries.Count -eq 0) 'Suspect selection remains distinct'
+
+ Reset-Fixture
+ $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -Auto -BackupPath $script:backup
+ Assert ($report.ExitCode -eq 0 -and $report.Scope -eq 'native-channel-settings-only') 'Configure succeeds only for requested channel settings'
+ Assert ($global:WelaChannelFixture.Writes.Count -eq 3) 'Configure changes only three declared channels'
+ Assert ($global:WelaChannelFixture.States[$script:capi].IsEnabled -and $global:WelaChannelFixture.States[$script:capi].SecurityDescriptor -eq 'fixture-granted') 'CAPI2 enablement and ACL are read back'
+ Assert (-not $global:WelaChannelFixture.States[$script:app].IsEnabled -and $global:WelaChannelFixture.States[$script:app].SecurityDescriptor -eq 'fixture-original') 'AppLocker size control preserves disabled state and ACL'
+ Assert ($global:WelaChannelFixture.States[$script:driver].MaximumSizeInBytes -eq 52494336) 'DriverFrameworks applied size matches rounded source bytes'
+ Assert (@($global:WelaChannelFixture.Writes | Where-Object { ($_ -join ' ') -match '/[ar][bt]:' }).Count -eq 0) 'No retention settings are modified'
+ Assert ($report.Controls[0].Access.State -eq 'GrantPresent' -and $report.Controls[0].Access.EffectiveReadAccess -eq 'Not tested') 'Structural readback never becomes an effective-access claim'
+ $json = @(Get-Content (Join-Path $script:backup 'before.jsonl') | ForEach-Object { $_ | ConvertFrom-Json })
+ Assert ($json[0].Before.MaximumSizeInBytes -eq 1048576 -and $json[0].Before.SecurityDescriptor -eq 'fixture-original' -and $json[0].Before.LogMode -eq 'Retain') 'Journal includes original bytes, full descriptor and retention mode'
+
+ Reset-Fixture
+ $global:WelaChannelFixture.States[$script:capi].MaximumSizeInBytes = [long]4294967296
+ $report = Invoke-WelaNativeChannelCommand -Action Configure -Auto -BackupPath $script:backup
+ Assert ($global:WelaChannelFixture.States[$script:capi].MaximumSizeInBytes -eq 4294967296) 'Existing larger buffer is preserved'
+ Assert (@($global:WelaChannelFixture.Writes | Where-Object { ($_ -join ' ') -like '*/ca:*' }).Count -eq 0) 'No ACL change without separate opt-in'
+ Assert ($report.Controls[0].Access.State -eq 'GrantRequired' -and $report.Controls[0].Prerequisites.Count -ge 2) 'Omitted ACL opt-in remains an unmet profile prerequisite'
+
+ Reset-Fixture
+ $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -DryRun -BackupPath $script:backup
+ Assert ($report.DryRun -and $report.Skipped -eq 3 -and $global:WelaChannelFixture.Writes.Count -eq 0 -and -not (Test-Path $script:backup)) 'Dry run does no native writes and creates no backup directory'
+ Reset-Fixture
+ $global:WelaChannelFixture.Prompt = 'n'
+ $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -BackupPath $script:backup
+ Assert ($report.Skipped -eq 3 -and $global:WelaChannelFixture.Writes.Count -eq 0) 'Declining prompts preserves every channel'
+
+ foreach ($driftRead in @(2, 3, 5)) {
+ Reset-Fixture; $global:WelaChannelFixture.DriftRead = $driftRead
+ $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -Auto -QuerySet Baseline -BackupPath $script:backup
+ Assert ($report.ExitCode -eq 1) "Drift at observation $driftRead cannot report success"
+ $writes = @($global:WelaChannelFixture.Writes | Where-Object { $_[1] -eq $script:capi })
+ Assert ($writes.Count -eq $(if ($driftRead -eq 5) { 1 } else { 0 })) "Plan-to-initial/prewrite drift rejects stale ACL; final drift is detected ($driftRead)"
+ }
+ foreach ($failure in @('native', 'false-success', 'denied', 'missing', 'acl')) {
+ Reset-Fixture; $global:WelaChannelFixture.Failure = $failure
+ if ($failure -eq 'denied') { $global:WelaChannelFixture.States[$script:capi].State = 'Unknown'; $global:WelaChannelFixture.States[$script:capi].SecurityDescriptor = $null }
+ if ($failure -eq 'missing') { $global:WelaChannelFixture.States[$script:capi].State = 'Not installed' }
+ if ($failure -eq 'acl') { $global:WelaChannelFixture.States[$script:capi].SecurityDescriptor = 'fixture-deny' }
+ $report = Invoke-WelaNativeChannelCommand -Action Configure -GrantEventLogReaders -Auto -BackupPath $script:backup
+ Assert ($report.ExitCode -eq 1 -and $report.Results[0].Status -eq 'Failed') "Failure $failure remains explicit and nonzero"
+ if ($failure -in @('denied', 'missing', 'acl')) { Assert (@($global:WelaChannelFixture.Writes | Where-Object { $_[1] -eq $script:capi }).Count -eq 0) "$failure never writes CAPI2" }
+ }
+ Reset-Fixture
+ $plan = @(Get-WelaNativeChannelPlan -Profile $script:profile -GrantEventLogReaders)
+ $context = New-WelaConfigurationContext -Auto -BackupPath $script:backup
+ New-Item -ItemType Directory -Path (Join-Path $script:backup 'before.jsonl') | Out-Null
+ Set-WelaNativeChannelControls -Context $context -Plan $plan -Profile $script:profile.id
+ Assert ($global:WelaChannelFixture.Writes.Count -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Journal failure blocks all writes'
+ $caught = $false; try { Invoke-WelaNativeChannelCommand -Action Audit -DryRun } catch { $caught = $true }
+ Assert $caught 'Unsupported dry-run action is rejected before reads/writes'
+ Reset-Fixture
+ $report = Invoke-WelaNativeChannelCommand -Action Plan -ResultsPath (Join-Path $script:backup 'missing/results.json')
+ Assert ($report.ExitCode -eq 1) 'Failed report export has a nonzero result'
+ Write-Host "PASS: $script:assertions native channel command/runner assertions. No Windows settings were changed."
+} finally {
+ $env:OS = $savedOS
+ & $module { Remove-Item Function:script:Get-WelaNativeChannel }
+ Remove-Variable -Name WelaChannelFixture -Scope Global -ErrorAction SilentlyContinue
+ foreach ($path in $script:cleanup) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
+}
diff --git a/tests/NativeChannelAccess.Windows.Tests.ps1 b/tests/NativeChannelAccess.Windows.Tests.ps1
new file mode 100644
index 00000000..9ad1d6ca
--- /dev/null
+++ b/tests/NativeChannelAccess.Windows.Tests.ps1
@@ -0,0 +1,86 @@
+# Real Windows descriptor API tests and read-only metadata/CLI smoke. No channel writes.
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/EventLogSettings.psm1') -Force
+Import-Module (Join-Path $repo 'modules/NativeProviders.psm1') -Force
+Import-Module (Join-Path $repo 'modules/NativeChannelAccess.psm1') -Force
+$script:assertions = 0
+function Assert($Condition, [string]$Message) {
+ if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++
+}
+function Binary($Value) {
+ $bytes = New-Object byte[] $Value.BinaryLength
+ $Value.GetBinaryForm($bytes, 0)
+ [Convert]::ToBase64String($bytes)
+}
+function Check-Preservation([string]$Sddl) {
+ $before = [System.Security.AccessControl.RawSecurityDescriptor]::new($Sddl)
+ $plan = Get-WelaChannelAccessPlan -SecurityDescriptor $Sddl
+ Assert ($plan.State -eq 'GrantRequired') "Descriptor supports lossless append: $($plan.Diagnostic)"
+ $after = [System.Security.AccessControl.RawSecurityDescriptor]::new($plan.ProposedDescriptor)
+ Assert ($before.Owner -eq $after.Owner -and $before.Group -eq $after.Group) 'Owner/group are retained'
+ Assert ($before.ControlFlags -eq $after.ControlFlags -and $before.ResourceManagerControl -eq $after.ResourceManagerControl) 'Control flags are retained'
+ Assert (($null -eq $before.SystemAcl -and $null -eq $after.SystemAcl) -or ((Binary $before.SystemAcl) -ceq (Binary $after.SystemAcl))) 'Complete SACL bytes are retained'
+ Assert ($after.DiscretionaryAcl.Count -eq $before.DiscretionaryAcl.Count + 1) 'Exactly one DACL ACE is added'
+ $j = 0
+ for ($i = 0; $i -lt $after.DiscretionaryAcl.Count; $i++) {
+ if ($i -eq $plan.AddedAceIndex) {
+ $ace = $after.DiscretionaryAcl[$i]
+ Assert ($ace.SecurityIdentifier.Value -eq 'S-1-5-32-573' -and $ace.AccessMask -eq 1 -and $ace.AceFlags -eq 0 -and -not $ace.IsCallback) 'New ACE is precisely unconditional Event Log Readers read (no write/clear)'
+ } else {
+ Assert ((Binary $before.DiscretionaryAcl[$j]) -ceq (Binary $after.DiscretionaryAcl[$i])) 'Every preexisting ACE stays byte-identical and in order'
+ $j++
+ }
+ }
+ Assert ($plan.EffectiveReadAccess -eq 'Not tested') 'Structural grant does not prove effective token access'
+ $second = Get-WelaChannelAccessPlan -SecurityDescriptor $plan.ProposedDescriptor
+ Assert ($second.State -eq 'GrantPresent' -and -not $second.ProposedDescriptor) 'Repeated planning does not duplicate the ACE'
+ Assert (Test-WelaChannelDescriptorEqual $plan.ProposedDescriptor $after.GetSddlForm('All')) 'Binary descriptor comparison handles Windows SDDL formatting'
+ Assert (-not (Test-WelaChannelDescriptorEqual $Sddl $plan.ProposedDescriptor)) 'Descriptor comparison detects the added ACE'
+}
+
+Check-Preservation 'O:BAG:SYD:PAI(A;;0x7;;;BA)(A;;0x2;;;AU)(A;ID;0x1;;;SY)S:AI(AU;SAFA;0x1;;;WD)'
+Check-Preservation 'O:BAG:SYD:(OA;;0x2;00112233-4455-6677-8899-aabbccddeeff;;AU)(A;;0x7;;;BA)'
+Check-Preservation 'O:BAG:SYD:(A;;0x2;;;S-1-5-32-573)(A;;0x7;;;BA)'
+foreach ($sddl in @('O:BAG:SYD:(A;;0x1;;;S-1-5-32-573)', 'O:BAG:SYD:(A;;0x7;;;S-1-5-32-573)')) {
+ $result = Get-WelaChannelAccessPlan $sddl
+ Assert ($result.State -eq 'GrantPresent' -and -not $result.ProposedDescriptor -and $result.EffectiveReadAccess -eq 'Not tested') 'Existing read/superset permission is preserved without claiming event access'
+}
+foreach ($sddl in @('', 'invalid', 'O:BAG:SY', 'O:BAG:SYD:NO_ACCESS_CONTROL', 'O:BAG:SYD:(D;;0x1;;;WD)(A;;0x7;;;BA)', 'O:BAG:SYD:(D;;GR;;;WD)(A;;0x7;;;BA)')) {
+ $result = Get-WelaChannelAccessPlan $sddl
+ Assert ($result.State -eq 'ManualReview' -and -not $result.ProposedDescriptor) 'Missing, invalid, null and denied descriptors refuse automatic modification'
+}
+# The original unknown ACE bytes must never be discarded. SDDL has no representation
+# for arbitrary custom ACEs; parsing/planning must refuse instead of replacing them.
+$raw = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:SYD:(A;;0x7;;;BA)')
+$raw.DiscretionaryAcl.InsertAce(1, [System.Security.AccessControl.CustomAce]::new([System.Security.AccessControl.AceType]127, [System.Security.AccessControl.AceFlags]::None, [byte[]]@(0, 0, 0, 0)))
+$binaryBefore = Binary $raw
+$refused = $false
+try {
+ $sddl = $raw.GetSddlForm('All')
+ $refused = (Get-WelaChannelAccessPlan $sddl).State -eq 'ManualReview'
+} catch { $refused = $true }
+Assert ($refused -and (Binary $raw) -ceq $binaryBefore) 'Unsupported unknown ACEs are retained and mutation is refused'
+
+$profile = Get-WelaNativeChannelProfile
+$before = @{}
+foreach ($control in $profile.controls) { $before[$control.channel] = Get-WelaNativeChannel -Name $control.channel }
+# Exercise actual CLI dispatch and JSON export using live Windows read APIs.
+$out = Join-Path ([IO.Path]::GetTempPath()) ('wela-native-channel-live-' + [guid]::NewGuid().ToString('N') + '.json')
+$shell = (Get-Process -Id $PID).Path
+try {
+ & $shell -NoProfile -File (Join-Path $repo 'WELA.ps1') channel-settings -ChannelAction Plan -GrantEventLogReaders -ResultsPath $out
+ $cliExit = $LASTEXITCODE
+ $report = Get-Content -LiteralPath $out -Raw -ErrorAction Stop | ConvertFrom-Json
+ Assert ($cliExit -eq $report.ExitCode -and $cliExit -in @(0, 1)) 'Read-only CLI exit code agrees with its report (missing/unknown channels may return 1)'
+ Assert ($report.Action -eq 'Plan' -and $report.QueryInventory.Count -eq 18 -and $report.ForwardingReadiness -eq 'Not verified') 'Real CLI plan exports channel inventory without a forwarding claim'
+ Assert ($report.ExcludedQueries.Count -eq 2 -and @($report.QueryInventory | Where-Object { $_.Channel.Name -like '*Sysmon*' }).Count -eq 0) 'Live public output excludes non-native queries'
+ foreach ($control in $profile.controls) {
+ $first = $before[$control.channel]; $last = Get-WelaNativeChannel -Name $control.channel
+ Assert ($first.State -eq $last.State -and $first.MaximumSizeInBytes -eq $last.MaximumSizeInBytes -and $first.LogMode -eq $last.LogMode -and $first.SecurityDescriptor -ceq $last.SecurityDescriptor) 'Live plan leaves channel metadata unchanged (or detects concurrent external drift)'
+ $row = @($report.Controls | Where-Object { $_.Definition.channel -eq $control.channel })[0]
+ Assert ($row.Before.MaximumSizeInBytes -eq $first.MaximumSizeInBytes -and $row.Before.SecurityDescriptor -ceq $first.SecurityDescriptor) 'Live exported metadata matches the actual native reader'
+ }
+ Write-Host "PASS: $script:assertions real Windows ACL and read-only CLI assertions. Identity access, event generation and forwarding were not tested."
+ $global:LASTEXITCODE = 0 # A reported missing/manual-review channel is valid smoke evidence.
+} finally { Remove-Item -LiteralPath $out -Force -ErrorAction SilentlyContinue }
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 4d03a568..b996309e 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,8 @@
**改善:**
+- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security)
+
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 50139bca..af54d2ca 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,8 @@
**Improvements:**
+- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security)
+
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
From c89a28190a3e3c4ff7906354d1a44919c5e1d760 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:38:28 +0900
Subject: [PATCH 06/13] Bind Windows descriptor byte overload explicitly and
link PR 401
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
modules/NativeChannelAccess.psm1 | 6 ++++--
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
5 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index f896b363..ba439640 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security)
+- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0e5145e2..c2f85f26 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security)
+- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
diff --git a/modules/NativeChannelAccess.psm1 b/modules/NativeChannelAccess.psm1
index 2e1942c4..5af21829 100644
--- a/modules/NativeChannelAccess.psm1
+++ b/modules/NativeChannelAccess.psm1
@@ -65,7 +65,9 @@ function Get-WelaChannelAccessPlan {
if ($unknownAce) { throw 'An unknown ACE requires manual review; the descriptor is preserved without mutation.' }
if ($deny) { throw 'A read-deny ACE may affect the forwarding token; the descriptor is preserved for manual review.' }
if ($grant) { $result.State = 'GrantPresent'; return [pscustomobject]$result }
- $copy = [System.Security.AccessControl.RawSecurityDescriptor]::new((Get-WelaDescriptorBytes $original), 0)
+ # Bind the binary overload explicitly on Windows PowerShell 5.1.
+ [byte[]]$originalBytes = Get-WelaDescriptorBytes $original
+ $copy = [System.Security.AccessControl.RawSecurityDescriptor]::new($originalBytes, 0)
$newAce = [System.Security.AccessControl.CommonAce]::new(
[System.Security.AccessControl.AceFlags]::None,
[System.Security.AccessControl.AceQualifier]::AccessAllowed, 1,
@@ -84,7 +86,7 @@ function Get-WelaChannelAccessPlan {
}
$result.State = 'GrantRequired'; $result.ProposedDescriptor = $sddl; $result.AddedAceIndex = $index
} catch {
- $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message
+ $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message + ' [' + $_.InvocationInfo.ScriptLineNumber + ']'
}
[pscustomobject]$result
}
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index b996309e..18e19399 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (issue #367) (@Shirofune-Security)
+- Microsoft WEF Appendix Cのチャネルを監査・計画・設定する任意実行の`channel-settings`を追加しました。CAPI2の有効化、原典の正確なバイト数、明示的に指定したEvent Log Readersの読み取りACEに対応します。既存のセキュリティ記述子・ACEと大きいバッファを保持し、安全に扱えないACLは変更しません。共通の復旧記録、書き込み直前の状態確認と読み戻しで失敗を報告します。Appendix E/Fの標準チャネル一覧からSysmonとEMETを除外し、実際のIDによるアクセス・イベント生成・収集は未検証と表示します。Windowsラボでの検証は別途必要です。 (#401) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index af54d2ca..ee24dc67 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (issue #367) (@Shirofune-Security)
+- Added opt-in `channel-settings` audit, plan and configure actions for Microsoft WEF Appendix C, including CAPI2 enablement, exact source byte sizes and an explicitly requested Event Log Readers read ACE. Existing descriptor components/ACEs and larger buffers are preserved or unsafe ACL edits are refused; shared journaling, fresh-state guards and readback report failures. Native Appendix E/F channel inventories exclude Sysmon/EMET and retain unverified identity access, generation and ingestion prerequisites. Windows lab evidence remains pending. (#401) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
From 75fd28a3d543ce146929a6f841f180b4a7e1265b Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:40:00 +0900
Subject: [PATCH 07/13] Use integer masks for byte-backed ACE flags on
PowerShell 5.1
---
modules/NativeChannelAccess.psm1 | 7 ++++---
1 file changed, 4 insertions(+), 3 deletions(-)
diff --git a/modules/NativeChannelAccess.psm1 b/modules/NativeChannelAccess.psm1
index 5af21829..0721a649 100644
--- a/modules/NativeChannelAccess.psm1
+++ b/modules/NativeChannelAccess.psm1
@@ -55,7 +55,8 @@ function Get-WelaChannelAccessPlan {
$grant = $false; $deny = $false; $unknownAce = $false
foreach ($ace in $original.DiscretionaryAcl) {
if ($ace -isnot [System.Security.AccessControl.KnownAce]) { $unknownAce = $true; continue }
- if ($ace.AceFlags -band [System.Security.AccessControl.AceFlags]::InheritOnly) { continue }
+ # PowerShell 5.1 cannot bitwise-cast byte-backed AceFlags enums.
+ if ([int]$ace.AceFlags -band [int][System.Security.AccessControl.AceFlags]::InheritOnly) { continue }
$readMask = ($ace.AccessMask -band 1) -or ($ace.AccessMask -band 268435456) -or ($ace.AccessMask -band [int]::MinValue)
if ($readMask -and $ace.AceQualifier -eq [System.Security.AccessControl.AceQualifier]::AccessDenied) { $deny = $true }
if ($ace -is [System.Security.AccessControl.CommonAce] -and -not $ace.IsCallback -and
@@ -76,7 +77,7 @@ function Get-WelaChannelAccessPlan {
# Place the explicit allow before inherited entries; do not canonicalize others.
$index = $copy.DiscretionaryAcl.Count
for ($i = 0; $i -lt $copy.DiscretionaryAcl.Count; $i++) {
- if ($copy.DiscretionaryAcl[$i].AceFlags -band [System.Security.AccessControl.AceFlags]::Inherited) { $index = $i; break }
+ if ([int]$copy.DiscretionaryAcl[$i].AceFlags -band [int][System.Security.AccessControl.AceFlags]::Inherited) { $index = $i; break }
}
$copy.DiscretionaryAcl.InsertAce($index, $newAce)
$sddl = $copy.GetSddlForm([System.Security.AccessControl.AccessControlSections]::All)
@@ -86,7 +87,7 @@ function Get-WelaChannelAccessPlan {
}
$result.State = 'GrantRequired'; $result.ProposedDescriptor = $sddl; $result.AddedAceIndex = $index
} catch {
- $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message + ' [' + $_.InvocationInfo.ScriptLineNumber + ']'
+ $result.State = 'ManualReview'; $result.Diagnostic = $_.Exception.Message
}
[pscustomobject]$result
}
From 5f240d8062ce6cb6fdd86293aae0efe64de321cf Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:40:08 +0900
Subject: [PATCH 08/13] Verify locked AppLocker import bytes and reject ignored
options
---
WELA.ps1 | 6 ++++
docs/applocker-readiness.md | 4 ++-
scripts/AppLockerReadiness.ps1 | 27 +++++++++++++++--
tests/AppLockerReadiness.Tests.ps1 | 34 ++++++++++++++++++++--
tests/AppLockerReadiness.Windows.Tests.ps1 | 8 +++--
5 files changed, 70 insertions(+), 9 deletions(-)
diff --git a/WELA.ps1 b/WELA.ps1
index ff50a3c5..741c73a4 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -1707,6 +1707,12 @@ Write-Host ""
Write-Host "WELA v$WELAVersion - $WELAReleaseName"
Write-Host ""
+if (($PSBoundParameters.ContainsKey('AppLockerAction') -or $AppLockerPolicyPath) -and $Cmd -ne 'applocker-readiness') {
+ throw '-AppLockerAction and -AppLockerPolicyPath require applocker-readiness. No command was run.'
+}
+if ($Cmd -eq 'applocker-readiness' -and ($Profile -or $Baseline)) {
+ throw 'applocker-readiness uses its own operator-supplied policy, not -Profile or -Baseline. No command was run.'
+}
# Reject unsupported dry-run requests before reaching any command's mutation path.
if ($DryRun -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
diff --git a/docs/applocker-readiness.md b/docs/applocker-readiness.md
index ff3c93ec..26589a67 100644
--- a/docs/applocker-readiness.md
+++ b/docs/applocker-readiness.md
@@ -13,7 +13,9 @@ The default is read-only Audit. Windows 11 clients and member servers running Se
## Scope and import safeguards
-Import accepts an **operator-supplied** native XML policy. Every included collection must explicitly be AuditOnly and contain rules. XML DTDs, namespaces, unknown collection types, duplicate IDs and policy extensions are rejected. The native `Test-AppLockerPolicy` cmdlet validates the prepared XML before it can be installed; it does not execute the test file. There are no generated blanket allow rules or default policy assumptions.
+AppLocker-specific options are rejected on unrelated commands; `-Profile` and `-Baseline` do not select AppLocker policy.
+
+Import accepts an **operator-supplied** native XML policy. Every included collection must explicitly be AuditOnly and contain rules. XML DTDs, namespaces, unknown collection types, duplicate IDs and policy extensions are rejected. The prepared file is created without overwriting existing files, locked against writes, and compared byte-for-byte (length and SHA-256) with the reviewed in-memory XML before native validation. The native `Test-AppLockerPolicy` cmdlet validates that same locked file before it can be installed; it does not execute the test file. There are no generated blanket allow rules or default policy assumptions.
Import only initializes an empty local/GP policy, or verifies an identical previously imported policy. Existing configured collections, existing enforcement (including NotConfigured collections with rules), unreadable policy, domain membership, observed enrollment/provider entries or unknown management state block import. Use the organization's policy authority to manage those hosts. The workflow uses `Set-AppLockerPolicy -Merge`, retains original policy XML in the recovery journal, rechecks state before writing, and verifies local collection content again after writing and at completion. It does not replace an existing policy. An import failure is reported with a nonzero exit code. Dry-run makes no policy or recovery-file changes.
diff --git a/scripts/AppLockerReadiness.ps1 b/scripts/AppLockerReadiness.ps1
index 89753bc0..9efca568 100644
--- a/scripts/AppLockerReadiness.ps1
+++ b/scripts/AppLockerReadiness.ps1
@@ -154,6 +154,19 @@ function Assert-WelaAppLockerImportSafe {
if ($Snapshot.LocalPolicy.Policy.Collections.Count -or $Snapshot.EffectiveGpPolicy.Policy.Collections.Count) { throw 'Existing policy is preserved. Import only initializes an empty local/GP policy; it never replaces a configured policy.' }
}
+function New-WelaAppLockerImportReadLock {
+ param([string]$Path, [string]$Xml)
+ # CreateNew refuses a pre-existing file/link in the backup directory. Native
+ # readers generally require that the writer handle has already been closed.
+ $writer = [IO.File]::Open($Path, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
+ try {
+ $bytes = [Text.Encoding]::UTF8.GetBytes($Xml)
+ $writer.Write($bytes, 0, $bytes.Length)
+ $writer.Flush()
+ } finally { $writer.Dispose() }
+ return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
+}
+
function Set-WelaAppLockerAuditPolicy {
param($Context, $Desired)
$state = @{ Desired=$Desired; Before=$null; Context=$Context }
@@ -167,12 +180,22 @@ function Set-WelaAppLockerAuditPolicy {
if (-not (Get-Command Set-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Set-AppLockerPolicy is unavailable in this session.' }
# Import the validated in-memory snapshot, not a mutable operator source file.
$path = Join-Path $state.Context.BackupPath 'appLocker-audit-import.xml'
- [IO.File]::WriteAllText($path, $state.Desired.Xml, (New-Object Text.UTF8Encoding($false)))
if (-not (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue)) { throw 'Test-AppLockerPolicy is unavailable; native schema validation is required before import.' }
# Deny concurrent modification/deletion of the prepared XML while both
# native cmdlets consume it; they need only read access.
- $lock = [IO.File]::Open($path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
+ $lock = New-WelaAppLockerImportReadLock -Path $path -Xml $state.Desired.Xml
try {
+ # The file can be replaced between writer-close and read-lock-open.
+ # Validate the locked bytes against the already reviewed snapshot,
+ # since native schema validation alone also accepts enforcing XML.
+ $expectedBytes = [Text.Encoding]::UTF8.GetBytes($state.Desired.Xml)
+ if ($lock.Length -ne $expectedBytes.Length) { throw 'Prepared AppLocker XML changed before its read lock; no policy was imported.' }
+ $hasher = [Security.Cryptography.SHA256]::Create()
+ try {
+ $expectedHash = [Convert]::ToBase64String($hasher.ComputeHash($expectedBytes))
+ $actualHash = [Convert]::ToBase64String($hasher.ComputeHash($lock))
+ if ($actualHash -cne $expectedHash) { throw 'Prepared AppLocker XML changed before its read lock; no policy was imported.' }
+ } finally { $hasher.Dispose() }
$validation = @(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
if (-not $validation.Count) { throw 'Native policy validation returned no result; no policy was imported.' }
$immediate = Get-WelaAppLockerReadiness
diff --git a/tests/AppLockerReadiness.Tests.ps1 b/tests/AppLockerReadiness.Tests.ps1
index b805578e..5840b61e 100644
--- a/tests/AppLockerReadiness.Tests.ps1
+++ b/tests/AppLockerReadiness.Tests.ps1
@@ -20,7 +20,7 @@ function Reset-Fixture {
$script:localXml=''; $script:effectiveXml=$script:localXml
$script:serviceState='Running'; $script:serviceMode='Auto'; $script:channelEnabled=$true
$script:domain=$false; $script:managed=@(); $script:unknownPolicy=$false; $script:writes=0; $script:readCount=0
- $script:race=$false; $script:reject=$false; $script:drift=$false
+ $script:race=$false; $script:reject=$false; $script:drift=$false; $script:tamper=$false; $script:validations=0
}
function Get-WelaAppLockerHost { [pscustomobject]@{Status='Candidate'; Is64BitProcess=$true; PartOfDomain=$script:domain} }
function Get-WelaAppLockerManagement { [pscustomobject]@{Status='Observed'; ManagementEntries=$script:managed; CspPolicyState='Unknown'} }
@@ -37,7 +37,15 @@ function Get-WelaAppLockerPolicySnapshot {
$value=if ($Scope -eq 'Local') {$script:localXml} else {$script:effectiveXml}
[pscustomobject]@{Status='Observed'; Policy=(ConvertFrom-WelaAppLockerXml -Xml $value)}
}
-function Test-AppLockerPolicy { [CmdletBinding()]param($XmlPolicy,$Path,$User) [pscustomobject]@{PolicyDecision='Allowed'} }
+$script:originalImportFile = ${function:New-WelaAppLockerImportReadLock}
+function New-WelaAppLockerImportReadLock {
+ param($Path,$Xml)
+ if (-not $script:tamper) { return & $script:originalImportFile -Path $Path -Xml $Xml }
+ # Simulate a file replaced before the read lock, without races or native policy calls.
+ [IO.File]::WriteAllText($Path, $Xml.Replace('AuditOnly', 'Enabled').Replace('', ' '), (New-Object Text.UTF8Encoding($false)))
+ return [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::Read)
+}
+function Test-AppLockerPolicy { [CmdletBinding()]param($XmlPolicy,$Path,$User) $script:validations++; [pscustomobject]@{PolicyDecision='Allowed'} }
function Set-AppLockerPolicy {
[CmdletBinding()]param($XmlPolicy,[switch]$Merge)
if (-not $Merge) { throw 'Import must never replace a policy.' }
@@ -68,9 +76,10 @@ Reset-Fixture; $script:unknownPolicy=$true
Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'readable'
$cleanup=@()
try {
- foreach ($scenario in @('apply','dry','race','failure','drift','existing')) {
+ foreach ($scenario in @('apply','dry','race','failure','drift','existing','tamper')) {
Reset-Fixture
if ($scenario -eq 'race') {$script:race=$true}
+ if ($scenario -eq 'tamper') {$script:tamper=$true}
if ($scenario -eq 'failure') {$script:reject=$true}
if ($scenario -eq 'drift') {$script:drift=$true}
if ($scenario -eq 'existing') {$script:localXml=$xml;$script:effectiveXml=$xml}
@@ -86,11 +95,30 @@ try {
Assert ($script:writes -eq 1 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Reapplying same policy should not write.'
}
'dry' { Assert ($script:writes -eq 0 -and -not (Test-Path $path)) 'Dry-run must not write policy or recovery files.' }
+ 'tamper' { Assert ($script:writes -eq 0 -and $script:validations -eq 0 -and $result.ExitCode -eq 1 -and $result.Results[0].Diagnostic -match 'changed before its read lock') 'Altered prepared XML must fail before native validation or import, including equal-length mode tampering.' }
'race' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 1) 'Concurrent enforcement must block merge.' }
'failure' { Assert ($result.ExitCode -eq 1) 'Native write failure must propagate.' }
'drift' { Assert ($result.ExitCode -eq 1) 'Final readback must detect policy drift.' }
'existing' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 0) 'Identical policy stays unchanged.' }
}
}
+ $path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-existing-'+[guid]::NewGuid().ToString('N')+'.xml');$cleanup+=$path
+ [IO.File]::WriteAllText($path,'Existing unrelated file')
+ $rejected=$false
+ try { $stream=& $script:originalImportFile -Path $path -Xml $xml; $stream.Dispose() } catch { $rejected=$true }
+ Assert ($rejected -and [IO.File]::ReadAllText($path) -eq 'Existing unrelated file') 'Prepared import creation cannot overwrite a pre-existing file/link.'
+ # Execute only actual top-level option guards; no command dispatcher/mutator.
+ $tokens=$null;$parseErrors=$null
+ $ast=[System.Management.Automation.Language.Parser]::ParseFile((Join-Path $PSScriptRoot '../WELA.ps1'),[ref]$tokens,[ref]$parseErrors)
+ Assert ($parseErrors.Count -eq 0) 'CLI option guards parse.'
+ $guard=$ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith("if ((`$PSBoundParameters.ContainsKey('AppLockerAction')") } | Select-Object -First 1
+ Assert ($null -ne $guard) 'Explicit AppLocker options must be guarded before dispatch.'
+ $exercise=[scriptblock]::Create('param($AppLockerAction,$AppLockerPolicyPath,$Cmd)' + [Environment]::NewLine + $guard.Extent.Text)
+ Assert-Throws { & $exercise -AppLockerAction Plan -Cmd configure } 'require applocker-readiness'
+ Assert-Throws { & $exercise -AppLockerPolicyPath 'operator.xml' -Cmd configure-sacl } 'require applocker-readiness'
+ & $exercise -AppLockerAction Plan -Cmd applocker-readiness
+ $guard=$ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.IfStatementAst] -and $_.Extent.Text.StartsWith("if (`$Cmd -eq 'applocker-readiness' -and (`$Profile") } | Select-Object -First 1
+ $Cmd='applocker-readiness';$Profile='wela-2.2.0';$Baseline=$null
+ Assert-Throws { & ([scriptblock]::Create($guard.Extent.Text)) } 'not -Profile or -Baseline'
} finally { foreach ($path in $cleanup) { Remove-Item -LiteralPath $path -Recurse -Force -ErrorAction SilentlyContinue } }
Write-Host "PASS: $count AppLocker readiness/import assertions; no Windows policies changed."
diff --git a/tests/AppLockerReadiness.Windows.Tests.ps1 b/tests/AppLockerReadiness.Windows.Tests.ps1
index ce75184a..8a1ca882 100644
--- a/tests/AppLockerReadiness.Windows.Tests.ps1
+++ b/tests/AppLockerReadiness.Windows.Tests.ps1
@@ -14,9 +14,11 @@ if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
try {
$xml=''
$policy=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
- [IO.File]::WriteAllText($path,$policy.Xml)
- $validation=@(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
- if (-not $validation.Count) { throw 'Native schema validation returned no decision.' }
+ $lock=New-WelaAppLockerImportReadLock -Path $path -Xml $policy.Xml
+ try {
+ $validation=@(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
+ if (-not $validation.Count) { throw 'Native schema validation returned no decision.' }
+ } finally { $lock.Dispose() }
} finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }
} else { Write-Host 'Native policy validation unavailable in this PowerShell session; importer will refuse.' }
Write-Host 'PASS: native read-only AppLocker observations. No Set-AppLockerPolicy or service changes.'
From d7f710c9ad04c5ffaa54fac06acdec47cbcb9b61 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:41:08 +0900
Subject: [PATCH 09/13] Restrict native WMI writes to SACL and verify privilege
cleanup
---
.github/workflows/wmi-namespace-auditing.yml | 6 ++
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
docs/wmi-namespace-auditing.md | 4 +-
scripts/WmiNamespaceAuditing.ps1 | 22 ++++--
.../WmiNamespaceAuditing.Privilege.Tests.ps1 | 76 +++++++++++++++++++
tests/WmiNamespaceAuditing.Windows.Tests.ps1 | 10 ++-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
9 files changed, 115 insertions(+), 11 deletions(-)
create mode 100644 tests/WmiNamespaceAuditing.Privilege.Tests.ps1
diff --git a/.github/workflows/wmi-namespace-auditing.yml b/.github/workflows/wmi-namespace-auditing.yml
index 3e991ecd..cad32532 100644
--- a/.github/workflows/wmi-namespace-auditing.yml
+++ b/.github/workflows/wmi-namespace-auditing.yml
@@ -24,9 +24,15 @@ jobs:
- name: Native read-only and in-memory writer adapter (Windows PowerShell 5.1)
shell: powershell
run: ./tests/WmiNamespaceAuditing.Windows.Tests.ps1
+ - name: In-memory privilege restoration failure paths (Windows PowerShell 5.1)
+ shell: powershell
+ run: ./tests/WmiNamespaceAuditing.Privilege.Tests.ps1
- name: Mocked namespace SACL regression tests (PowerShell 7)
shell: pwsh
run: ./tests/WmiNamespaceAuditing.Tests.ps1
- name: Native read-only and in-memory writer adapter (PowerShell 7)
shell: pwsh
run: ./tests/WmiNamespaceAuditing.Windows.Tests.ps1
+ - name: In-memory privilege restoration failure paths (PowerShell 7)
+ shell: pwsh
+ run: ./tests/WmiNamespaceAuditing.Privilege.Tests.ps1
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 28492827..c531d4a2 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0a164b0a..6de99e0b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
diff --git a/docs/wmi-namespace-auditing.md b/docs/wmi-namespace-auditing.md
index c550a944..13f1b3fd 100644
--- a/docs/wmi-namespace-auditing.md
+++ b/docs/wmi-namespace-auditing.md
@@ -35,7 +35,9 @@ The numeric subscription mask includes Execute Methods even though the reference
Run elevated with **SeSecurityPrivilege assigned** for Audit/Plan/Configure. WELA enables this privilege in its process while accessing the descriptor, restores the previous token state afterward, and requests privileges for the local WMI connection. Without it a provider can return a DACL while omitting the SACL; WELA refuses that ambiguous read. List only enumerates the supported root child namespaces and reports Present, NotInstalled or Unknown.
-Each GetSecurityDescriptor and SetSecurityDescriptor return code must be explicitly zero. Exceptions, denied/missing namespaces, incomplete descriptors, nonzero return codes, ineffective writes and failed read-back are failures. The journal stores the complete provider descriptor as JSON and MOF strings before the setter is called; nested entries cannot be truncated by the outer result serializer. Native objects are cloned rather than rebuilt from a shortened permission list. Existing ACEs, duplicate/unknown ACEs, DACL order, owner, group and other descriptor fields are preserved. The only control flag added is `SE_SACL_PRESENT` when needed. Provider representations that cannot round-trip unchanged fail verification; unknown entries are never deliberately simplified or discarded.
+Each GetSecurityDescriptor and SetSecurityDescriptor return code must be explicitly zero. Exceptions, denied/missing namespaces, incomplete descriptors, nonzero return codes, ineffective writes and failed read-back are failures. The journal stores the complete provider descriptor as JSON and MOF strings before the setter is called; nested entries cannot be truncated by the outer result serializer. Native objects are cloned rather than rebuilt from a shortened permission list. The native setter request clears `SE_DACL_PRESENT` and leaves DACL, owner and group null: the [documented provider contract](https://learn.microsoft.com/en-us/windows/win32/wmisdk/setsecuritydescriptor-method-in-class---systemsecurity) preserves those access fields rather than rewriting them. `SE_SACL_PRESENT` requests the SACL update. Full read-back still verifies DACL order, owner, group, other control flags and every original audit entry against the complete recovery snapshot. Unknown entries are never deliberately simplified or discarded.
+
+Privilege restoration runs even if connection disposal fails. Both enabling and restoring the token privilege check the API return value and last-error code; [AdjustTokenPrivileges](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-adjusttokenprivileges) can return success while reporting an unassigned privilege. A restoration error is reported as a failed operation, not silently treated as restored state.
Immediately before writing, WELA reads the full descriptor again and refuses to overwrite a changed snapshot. Read-back checks all original fields/ACE multiplicities and every requested exact audit entry. The final check detects descriptor drift after verification. This is not an atomic transaction with other administrators or management software: changes between the last read and the provider write remain possible. No automatic rollback overwrites concurrent changes.
diff --git a/scripts/WmiNamespaceAuditing.ps1 b/scripts/WmiNamespaceAuditing.ps1
index c3013b55..97d82629 100644
--- a/scripts/WmiNamespaceAuditing.ps1
+++ b/scripts/WmiNamespaceAuditing.ps1
@@ -60,7 +60,9 @@ namespace Wela {
try {
if (changed) {
TokenPrivileges ignored; uint required;
- if (!AdjustTokenPrivileges(token, false, ref previous, (uint)Marshal.SizeOf(typeof(TokenPrivileges)), out ignored, out required)) throw new Win32Exception(Marshal.GetLastWin32Error());
+ bool ok = AdjustTokenPrivileges(token, false, ref previous, (uint)Marshal.SizeOf(typeof(TokenPrivileges)), out ignored, out required);
+ int error = Marshal.GetLastWin32Error();
+ if (!ok || error != 0) throw new Win32Exception(error, "Restoring SeSecurityPrivilege failed; the previous token state could not be verified.");
}
} finally { CloseHandle(token); token=IntPtr.Zero; }
}
@@ -155,7 +157,10 @@ function Get-WelaWmiNamespaceSnapshot {
# Strings prevent JSON journal depth truncation of nested, unfamiliar ACEs.
[pscustomobject]@{ Namespace = $Namespace; DescriptorJson = ConvertTo-WelaWmiJson $data
DescriptorMof = $descriptor.GetText([System.Management.TextFormat]::Mof); SaclReadPrivilege = 'SeSecurityPrivilege enabled' }
- } finally { if ($connection) { $connection.Dispose() }; $privilege.Dispose() }
+ } finally {
+ try { if ($connection) { $connection.Dispose() } }
+ finally { $privilege.Dispose() }
+ }
}
function Set-WelaWmiNamespaceDescriptor {
@@ -188,14 +193,21 @@ function Set-WelaWmiNamespaceDescriptor {
} finally { $aceClass.Dispose(); $trusteeClass.Dispose() }
}
$updated.SACL = [System.Management.ManagementBaseObject[]]$aces
- # Only SE_SACL_PRESENT is added when absent. Every other control bit stays.
- $updated.ControlFlags = [uint32]$descriptor.ControlFlags -bor [uint32]16
+ # SetSecurityDescriptor treats SE_DACL_PRESENT and non-null Owner/Group
+ # as requests to rewrite access permissions. Omit those fields explicitly
+ # so the provider preserves them, even if another writer races this call.
+ # Complete original fields remain in the journal and read-back comparison.
+ $updated.DACL = $null; $updated.Owner = $null; $updated.Group = $null
+ $updated.ControlFlags = ([uint32]$descriptor.ControlFlags -band [uint32]4294967291) -bor [uint32]16
$parameters = $connection.GetMethodParameters('SetSecurityDescriptor')
$parameters.Descriptor = $updated
$response = $connection.InvokeMethod('SetSecurityDescriptor', $parameters, $null)
Assert-WelaWmiReturnCode $response 'SetSecurityDescriptor'
'SACL update accepted; full descriptor preservation and audit entries require read-back verification. Event generation is unverified.'
- } finally { if ($connection) { $connection.Dispose() }; $privilege.Dispose() }
+ } finally {
+ try { if ($connection) { $connection.Dispose() } }
+ finally { $privilege.Dispose() }
+ }
}
function Test-WelaWmiDescriptorPreserved {
diff --git a/tests/WmiNamespaceAuditing.Privilege.Tests.ps1 b/tests/WmiNamespaceAuditing.Privilege.Tests.ps1
new file mode 100644
index 00000000..a2065d6c
--- /dev/null
+++ b/tests/WmiNamespaceAuditing.Privilege.Tests.ps1
@@ -0,0 +1,76 @@
+# Compile the production privilege lifecycle with in-memory native API substitutes.
+# No process token or live WMI namespace is modified by this test.
+$ErrorActionPreference = 'Stop'
+$repo = Split-Path $PSScriptRoot -Parent
+$source = Get-Content -LiteralPath (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1') -Raw
+. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1')
+$script:assertions = 0
+function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ }
+$match = [regex]::Match($source, "(?s)Add-Type -TypeDefinition @'\r?\n(.*?)\r?\n'@ -ErrorAction Stop")
+Assert $match.Success 'Production privilege helper located'
+$csharp = $match.Groups[1].Value.Replace('namespace Wela {', 'namespace WelaPrivilegeFixture {')
+# Replace only external API declarations/error reads, retaining constructor and
+# Dispose control flow from the shipped helper rather than mirroring that logic.
+$csharp = [regex]::Replace($csharp, '(?m)^ \[DllImport[^\r\n]+\r?\n', '')
+$csharp = $csharp.Replace('Marshal.GetLastWin32Error()', 'TestError')
+$native = @'
+ public static int TestError, EnableError, RestoreError, AdjustCalls, CloseCalls;
+ public static bool RestoreSuccess = true;
+ public static void Reset() { TestError=EnableError=RestoreError=AdjustCalls=CloseCalls=0; RestoreSuccess=true; }
+ static IntPtr GetCurrentProcess() { return (IntPtr)1; }
+ static bool CloseHandle(IntPtr handle) { CloseCalls++; return true; }
+ static bool OpenProcessToken(IntPtr process, uint access, out IntPtr token) { token=(IntPtr)2; return true; }
+ static bool LookupPrivilegeValue(string system, string name, out Luid luid) { luid=new Luid(); return true; }
+ static bool AdjustTokenPrivileges(IntPtr token, bool disable, ref TokenPrivileges current, uint size, out TokenPrivileges previous, out uint required) {
+ previous=current; previous.Attributes=0; required=16;
+ AdjustCalls++; TestError=AdjustCalls==1 ? EnableError : RestoreError;
+ return AdjustCalls==1 || RestoreSuccess;
+ }
+'@
+$csharp = $csharp.Replace(' IntPtr token;', $native + "`n IntPtr token;")
+Assert ($csharp -notmatch '\[DllImport') 'All token API imports are replaced before compilation'
+Add-Type -TypeDefinition $csharp -ErrorAction Stop
+$type = [WelaPrivilegeFixture.WmiSecurityPrivilege]
+$type::Reset()
+$instance = [WelaPrivilegeFixture.WmiSecurityPrivilege]::new()
+$instance.Dispose(); $instance.Dispose()
+Assert ($type::AdjustCalls -eq 2 -and $type::CloseCalls -eq 1) 'Normal restoration executes once and closes the token once'
+foreach ($restoreError in @(1300, 5)) {
+ $type::Reset(); $type::RestoreError = $restoreError
+ $instance = [WelaPrivilegeFixture.WmiSecurityPrivilege]::new()
+ $failed = $false
+ try { $instance.Dispose() } catch { $failed = $_.Exception.InnerException.NativeErrorCode -eq $restoreError }
+ Assert $failed 'A true AdjustTokenPrivileges return with nonzero last error is a restoration failure'
+ Assert ($type::CloseCalls -eq 1) 'Failed privilege restoration still closes the token handle'
+}
+$type::Reset(); $type::RestoreError = 5; $type::RestoreSuccess = $false
+$instance = [WelaPrivilegeFixture.WmiSecurityPrivilege]::new()
+$failed = $false; try { $instance.Dispose() } catch { $failed = $true }
+Assert ($failed -and $type::CloseCalls -eq 1) 'False API restoration result is reported and handle is closed'
+$type::Reset(); $type::EnableError = 1300
+$failed = $false; try { [WelaPrivilegeFixture.WmiSecurityPrivilege]::new() } catch { $failed = $true }
+Assert ($failed -and $type::AdjustCalls -eq 1 -and $type::CloseCalls -eq 1) 'Unavailable SeSecurityPrivilege refuses the operation and closes its handle'
+
+# Exercise the production PowerShell cleanup paths with a throwing connection.
+function Initialize-WelaWmiInterop { }
+$script:disposed = 0
+$script:privilegeFixture = [pscustomobject]@{}
+$script:privilegeFixture | Add-Member ScriptMethod Dispose { $script:disposed++ }
+function New-Object {
+ param([string]$TypeName, [object[]]$ArgumentList)
+ if ($TypeName -eq 'Wela.WmiSecurityPrivilege') { return $script:privilegeFixture }
+ throw "Unexpected construction in failure fixture: $TypeName"
+}
+$script:connectionFixture = [pscustomobject]@{}
+$script:connectionFixture | Add-Member ScriptMethod Dispose { throw 'fixture COM cleanup failure' }
+function New-WelaWmiConnection { param($Namespace) return $script:connectionFixture }
+function Get-WelaWmiNativeDescriptor { param($Connection) throw 'fixture descriptor read failure' }
+foreach ($operation in @('Get', 'Set')) {
+ $before = $script:disposed; $failed = $false
+ try {
+ if ($operation -eq 'Get') { Get-WelaWmiNamespaceSnapshot 'root\cimv2' }
+ else { Set-WelaWmiNamespaceDescriptor 'root\cimv2' '{}' @() }
+ } catch { $failed = $true }
+ Assert ($failed -and $script:disposed -eq $before + 1) "$operation restores privilege even when connection cleanup throws"
+}
+Write-Host "PASS: $script:assertions WMI privilege/cleanup assertions with in-memory APIs only."
diff --git a/tests/WmiNamespaceAuditing.Windows.Tests.ps1 b/tests/WmiNamespaceAuditing.Windows.Tests.ps1
index 7d38ca1f..db94a389 100644
--- a/tests/WmiNamespaceAuditing.Windows.Tests.ps1
+++ b/tests/WmiNamespaceAuditing.Windows.Tests.ps1
@@ -48,7 +48,15 @@ Set-WelaWmiNamespaceDescriptor -Namespace 'root\cimv2' -ExpectedJson $expected -
Assert ($script:setCalls -eq 1 -and $script:captured -is [System.Management.ManagementBaseObject]) 'Production writer builds typed descriptor against fake provider only'
$original = $expected | ConvertFrom-Json
$captured = ConvertTo-WelaWmiData $script:captured
-Assert (Test-WelaWmiDescriptorPreserved $original $captured) 'Typed descriptor clone preserves DACL owner group and control flags'
+Assert ($null -eq $captured.DACL -and $null -eq $captured.Owner -and $null -eq $captured.Group) 'Native request omits access-permission fields instead of requesting that they be rewritten'
+Assert (([uint32]$captured.ControlFlags -band 4) -eq 0 -and ([uint32]$captured.ControlFlags -band 16) -eq 16) 'Native request uses only SACL-present mutation semantics, with DACL-present cleared'
+Assert ((ConvertTo-WelaWmiJson (ConvertTo-WelaWmiData $script:fixtureDescriptor)) -ceq $expected) 'Building the SACL-only request leaves the complete original descriptor unchanged'
+# Simulate the documented provider contract in memory: absent access fields and
+# SE_DACL_PRESENT preserve the current access permissions.
+$effective = $expected | ConvertFrom-Json
+$effective.SACL = $captured.SACL
+$effective.ControlFlags = [uint32]$effective.ControlFlags -bor 16
+Assert (Test-WelaWmiDescriptorPreserved $original $effective) 'SACL-only provider semantics retain every original non-SACL field'
Assert (@(Get-WelaWmiMissingAces $captured $definitions).Count -eq 0 -and @($captured.SACL).Count -eq 4) 'Actual Win32_ACE/Trustee objects carry all four exact masks and binary SIDs'
$script:returnCode = [uint32]9
$failed = $false
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 7c4cda0f..cd1e2f1c 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、特権・戻り値の確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 3f5e1e26..e929556b 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, privilege/return-code checks, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
From 38bceb3de158a46483537326e0cdf17d8f5209b7 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 05:42:06 +0900
Subject: [PATCH 10/13] Construct unknown ACE fixture without PowerShell enum
validation
---
tests/NativeChannelAccess.Windows.Tests.ps1 | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/tests/NativeChannelAccess.Windows.Tests.ps1 b/tests/NativeChannelAccess.Windows.Tests.ps1
index 9ad1d6ca..d669b60b 100644
--- a/tests/NativeChannelAccess.Windows.Tests.ps1
+++ b/tests/NativeChannelAccess.Windows.Tests.ps1
@@ -53,7 +53,7 @@ foreach ($sddl in @('', 'invalid', 'O:BAG:SY', 'O:BAG:SYD:NO_ACCESS_CONTROL', 'O
# The original unknown ACE bytes must never be discarded. SDDL has no representation
# for arbitrary custom ACEs; parsing/planning must refuse instead of replacing them.
$raw = [System.Security.AccessControl.RawSecurityDescriptor]::new('O:BAG:SYD:(A;;0x7;;;BA)')
-$raw.DiscretionaryAcl.InsertAce(1, [System.Security.AccessControl.CustomAce]::new([System.Security.AccessControl.AceType]127, [System.Security.AccessControl.AceFlags]::None, [byte[]]@(0, 0, 0, 0)))
+$raw.DiscretionaryAcl.InsertAce(1, [System.Security.AccessControl.CustomAce]::new(([Enum]::ToObject([System.Security.AccessControl.AceType], 127)), [System.Security.AccessControl.AceFlags]::None, [byte[]]@(0, 0, 0, 0)))
$binaryBefore = Binary $raw
$refused = $false
try {
From 072bcdf6afc1f3d87715b8c7141c5ba195fdc142 Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:20:32 +0900
Subject: [PATCH 11/13] Verify native WMI SACL flags on disposable Windows
namespaces
---
.github/workflows/wmi-namespace-auditing.yml | 25 ++++++
...paceAuditing.DisposableNamespace.Tests.ps1 | 85 +++++++++++++++++++
2 files changed, 110 insertions(+)
create mode 100644 tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1
diff --git a/.github/workflows/wmi-namespace-auditing.yml b/.github/workflows/wmi-namespace-auditing.yml
index cad32532..5c086ac2 100644
--- a/.github/workflows/wmi-namespace-auditing.yml
+++ b/.github/workflows/wmi-namespace-auditing.yml
@@ -36,3 +36,28 @@ jobs:
- name: In-memory privilege restoration failure paths (PowerShell 7)
shell: pwsh
run: ./tests/WmiNamespaceAuditing.Privilege.Tests.ps1
+ disposable-namespace:
+ # Mutations are restricted to newly created namespaces on hosted throwaway VMs.
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, windows-2025]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Real temporary-namespace SACL write/readback (Windows PowerShell 5.1)
+ shell: powershell
+ run: ./tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 -AllowDisposableNamespaceWrite -EvidencePath wmi-native-ps51.json
+ - name: Real temporary-namespace SACL write/readback (PowerShell 7)
+ shell: pwsh
+ run: ./tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 -AllowDisposableNamespaceWrite -EvidencePath wmi-native-ps7.json
+ - name: Record native descriptor evidence
+ if: always()
+ shell: pwsh
+ run: |
+ foreach ($path in @('wmi-native-ps51.json', 'wmi-native-ps7.json')) {
+ if (Test-Path -LiteralPath $path) {
+ Write-Host "Evidence: $path"
+ Get-Content -LiteralPath $path -Raw
+ }
+ }
diff --git a/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 b/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1
new file mode 100644
index 00000000..2ecabed1
--- /dev/null
+++ b/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1
@@ -0,0 +1,85 @@
+# Actual SACL writes, confined to fresh temporary namespaces on a disposable VM.
+# Existing namespaces are read only as the parent/factory; never passed to a setter.
+param([switch]$AllowDisposableNamespaceWrite, [string]$EvidencePath)
+$ErrorActionPreference = 'Stop'
+if (-not $AllowDisposableNamespaceWrite) { throw 'This integration test requires -AllowDisposableNamespaceWrite on a disposable Windows VM.' }
+if ($env:OS -ne 'Windows_NT') { throw 'Disposable-namespace integration requires Windows.' }
+$repo = Split-Path $PSScriptRoot -Parent
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1')
+Initialize-WelaWmiInterop
+$script:assertions = 0
+function Assert($Condition, [string]$Message) { if (-not $Condition) { throw "FAIL: $Message" }; $script:assertions++ }
+$evidence = [pscustomobject]@{
+ SchemaVersion = 1; Computer = $env:COMPUTERNAME; OperatingSystem = [Environment]::OSVersion.VersionString
+ PowerShell = $PSVersionTable.PSVersion.ToString(); StartedUtc = [DateTime]::UtcNow.ToString('o')
+ Scope = 'Real SACL write/readback on uniquely created root child namespaces only'
+ EventGeneration = 'Not tested'; Forwarding = 'Not tested'; Cases = @(); Complete = $false
+}
+$backup = Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-integration-' + [guid]::NewGuid().ToString('N'))
+try {
+ foreach ($flags in @(64, 66)) {
+ $name = 'WelaSaclTest_' + [guid]::NewGuid().ToString('N')
+ $namespace = 'root\' + $name
+ Assert ($namespace -match '^root\\WelaSaclTest_[0-9a-f]{32}$') 'Only the generated test namespace can receive writes'
+ $created = $false; $factory = $null; $instance = $null
+ $case = [pscustomobject]@{ Namespace=$namespace; AceFlags=$flags; Before=$null; After=$null; Result=$null; RepeatResult=$null; BeforeControlFlags=$null; ExpectedControlFlags=$null; AfterControlFlags=$null; Removed=$false }
+ $evidence.Cases += $case
+ try {
+ # CreateOnly is essential: never adopt or delete an existing namespace.
+ $factory = New-Object System.Management.ManagementClass -ArgumentList '\\.\root:__Namespace'
+ $instance = $factory.CreateInstance(); $instance.Name = $name
+ $options = New-Object System.Management.PutOptions
+ $options.Type = [System.Management.PutType]::CreateOnly
+ $createdPath = $instance.Put($options)
+ $created = $true
+ Assert ($createdPath.RelativePath -eq ('__NAMESPACE.Name="' + $name + '"')) 'Created namespace identity matches the generated name'
+ $before = Get-WelaWmiNamespaceSnapshot $namespace
+ $case.Before = $before
+ $beforeData = $before.DescriptorJson | ConvertFrom-Json
+ $case.BeforeControlFlags = [uint32]$beforeData.ControlFlags
+ $case.ExpectedControlFlags = [uint32]$beforeData.ControlFlags -bor 16
+ Assert (@($beforeData.SACL | Where-Object { $null -ne $_ }).Count -eq 0) 'Fixture exercises first SACL creation on a namespace with no existing audit ACEs'
+ # Reuse the real ASD root-default mask/SID, with the test target and
+ # explicit inheritance mode. Production profile scope is unchanged.
+ $definitions = @(Get-WelaWmiAuditDefinitions -Namespace 'root\default' -IncludeChildren)
+ $definitions[0].Namespace = $namespace; $definitions[0].AceFlags = [uint32]$flags
+ $entry = [pscustomobject]@{ Namespace=$namespace; Definitions=$definitions }
+ $context = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $backup ('first-' + $flags))
+ Set-WelaWmiAuditControls -Context $context -Plan @($entry)
+ $case.Result = Complete-WelaConfiguration -Context $context -Scope 'wmi-namespace-sacl-only'
+ $after = Get-WelaWmiNamespaceSnapshot $namespace
+ $case.After = $after
+ $afterData = $after.DescriptorJson | ConvertFrom-Json
+ $case.AfterControlFlags = [uint32]$afterData.ControlFlags
+ Write-Host "Native flags: mode=$flags before=$($case.BeforeControlFlags) expected=$($case.ExpectedControlFlags) after=$($case.AfterControlFlags)"
+ Assert ($case.Result.ExitCode -eq 0 -and $case.Result.Results[0].Status -eq 'Applied') 'Actual production runner accepts the provider readback after first SACL creation'
+ Assert ($case.AfterControlFlags -eq $case.ExpectedControlFlags) 'Provider control flags match the exact preservation contract for this tested host/mode'
+ Assert (Test-WelaWmiDescriptorPreserved $beforeData $afterData) 'Original access fields and existing ACEs survive the real SACL-only write'
+ Assert (@(Get-WelaWmiMissingAces $afterData $definitions).Count -eq 0) 'Native provider stores the requested SID/mask/outcome/inheritance'
+ $repeat = New-WelaConfigurationContext -Auto -BackupPath (Join-Path $backup ('repeat-' + $flags))
+ Set-WelaWmiAuditControls -Context $repeat -Plan @($entry)
+ $case.RepeatResult = Complete-WelaConfiguration -Context $repeat -Scope 'wmi-namespace-sacl-only'
+ Assert ($case.RepeatResult.ExitCode -eq 0 -and $case.RepeatResult.Results[0].Status -eq 'AlreadyCompliant') 'Repeated real configuration is idempotent'
+ Assert ((Get-WelaWmiNamespaceSnapshot $namespace).DescriptorJson -ceq $after.DescriptorJson) 'Repeat leaves the full descriptor unchanged'
+ } finally {
+ try {
+ if ($created) {
+ # The only deletion target is the instance this run created.
+ $instance.Delete()
+ $remaining = @(Get-CimInstance -Namespace root -ClassName __Namespace -Filter ("Name='$name'") -ErrorAction Stop)
+ Assert ($remaining.Count -eq 0) 'Owned temporary namespace was removed'
+ $case.Removed = $true
+ }
+ } finally {
+ if ($instance) { $instance.Dispose() }
+ if ($factory) { $factory.Dispose() }
+ }
+ }
+ }
+ $evidence.Complete = $true
+ Write-Host "PASS: $script:assertions disposable-namespace native SACL assertions. Event generation and forwarding were not tested."
+} finally {
+ if ($EvidencePath) { $evidence | ConvertTo-Json -Depth 25 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8 -ErrorAction Stop }
+ if (Test-Path -LiteralPath $backup) { Remove-Item -LiteralPath $backup -Recurse -Force -ErrorAction Stop }
+}
From bf12f186fb9983e968e725c6db1ac5fbc322321d Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:23:14 +0900
Subject: [PATCH 12/13] Record verified WMI control flags and disposable test
scope
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
docs/wmi-namespace-auditing.md | 21 ++++++++++++++++++++-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
5 files changed, 24 insertions(+), 5 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index c531d4a2..2f764ad4 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,7 +4,7 @@
**改善:**
-- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 使い捨てのServer 2022/2025名前空間でPowerShell 5.1/7の制御フラグ読み戻しと冪等性を検証した。 (#399) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6de99e0b..78d3141e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,7 +4,7 @@
**Improvements:**
-- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. Verified native control-flag readback and idempotence on disposable Server 2022/2025 namespaces under PowerShell 5.1/7. (#399) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
diff --git a/docs/wmi-namespace-auditing.md b/docs/wmi-namespace-auditing.md
index 13f1b3fd..b44c60b9 100644
--- a/docs/wmi-namespace-auditing.md
+++ b/docs/wmi-namespace-auditing.md
@@ -53,6 +53,25 @@ WELA separately observes the effective **Other Object Access Events** audit poli
Keep the new backup directory and result JSON outside temporary folders. `before.jsonl` contains each selected namespace's original `DescriptorJson` and `DescriptorMof`, namespace name and proposed entries. Compare these with a fresh Audit export before making any recovery change. In an elevated WMI Control (`wmimgmt.msc`), select the exact namespace, Security > Advanced > Auditing, and remove only entries that this run added after confirming they were absent from the original descriptor. Restore changed audit flags/masks from the original export if necessary; retain unrelated owner/group/DACL and newer administrative changes. WELA deliberately provides no blind whole-descriptor restore. An existing matching ACE was not created by this run and must not be removed. If descendant inheritance was enabled, inspect affected child namespaces independently and use a pre-change machine snapshot if a complete rollback is needed.
-CI uses synthetic descriptors, a real privileged read of root\cimv2, an in-memory native writer adapter and a read-only dry-run on Windows PowerShell 5.1/7. It never sends SetSecurityDescriptor to a live namespace. **Live SACL writes, benign local/remote event generation, child propagation and forwarding have not been verified by these tests.** Before deployment, use isolated patched snapshots of Windows 11, member server, domain controller and AD CS hosts; record descriptors/effective audit policy before and after, repeat configuration for idempotence, issue benign local and remote calls with known tokens, capture Security 4662 XML, and test the chosen WEF subscription and collector receipt. Validate namespace `ObjectName` and access masks, not EventID alone. These are pending acceptance labs, not claimed Sigma uplift.
+CI uses synthetic descriptors, a real privileged read of root\cimv2, an in-memory native writer adapter and a read-only dry-run on Windows PowerShell 5.1/7. A separate integration job performs real SACL writes only on uniquely created temporary namespaces on disposable Server 2022/2025 runners, verifies read-back/idempotence and deletes its own namespaces. It never changes the SACL of an existing namespace. **Writes to the five production target namespaces, Windows 11 behavior, benign local/remote event generation, child propagation and forwarding have not been verified by these tests.** Before deployment, use isolated patched snapshots of Windows 11, member server, domain controller and AD CS hosts; record descriptors/effective audit policy before and after, repeat configuration for idempotence, issue benign local and remote calls with known tokens, capture Security 4662 XML, and test the chosen WEF subscription and collector receipt. Validate namespace `ObjectName` and access masks, not EventID alone. These are pending acceptance labs, not claimed Sigma uplift.
Additional primary references: [SetSecurityDescriptor and preservation flags](https://learn.microsoft.com/en-us/windows/win32/wmisdk/setsecuritydescriptor-method-in-class---systemsecurity), [namespace access masks](https://learn.microsoft.com/en-us/windows/win32/wmisdk/namespace-access-rights-constants), [namespace inheritance flags](https://learn.microsoft.com/en-us/windows/win32/wmisdk/namespace-ace-flag-constants).
+
+## Native control-flag readback evidence
+
+The disposable-namespace test addresses [review comment 4052822447](https://github.com/Yamato-Security/WELA/pull/399#discussion_r4052822447) without weakening preservation checks. [The verified CI run](https://github.com/Yamato-Security/WELA/actions/runs/35436825928) used the real production SACL writer and configuration runner against eight fresh namespaces (two ACE flag modes, two PowerShell versions and two operating systems). Each started without a SACL, retained owner/group/DACL, passed first-write readback and an idempotent repeat, then was deleted. Full descriptor snapshots and cleanup results are in the job logs.
+
+| Host build | PowerShell | ACE flags tested | ControlFlags before | ControlFlags after |
+| --- | --- | --- | --- | --- |
+| Server 2022 / 20348 | 5.1.20348.5622, 7.6.6 | 64 and 66 | 32772 / `0x8004` | 32788 / `0x8014` |
+| Server 2025 / 26100 | 5.1.26100.33296, 7.6.5 | 64 and 66 | 32772 / `0x8004` | 32788 / `0x8014` |
+
+No extra auto-inherited/defaulted bit appeared in these cases. The existing `Before.ControlFlags | 0x10` equality is retained: an unexpected flag change still fails preservation verification. These results establish this provider behavior for the listed clean namespace scenarios, not every existing namespace or Windows version.
+
+To repeat on a **disposable Windows lab VM** (this is a mutating integration test):
+
+```powershell
+./tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 -AllowDisposableNamespaceWrite -EvidencePath wmi-native.json
+```
+
+The script accepts no target namespace. It uses generated `root\WelaSaclTest_` names, creates them with CreateOnly, verifies the returned identity, runs the writer only there, and removes only instances it created. The normal read-only test remains separate. It does not enable audit policy, generate controlled Security 4662 evidence or test forwarding. Namespace lifecycle follows Microsoft's [__Namespace contract](https://learn.microsoft.com/en-us/windows/win32/wmisdk/--namespace); SACL-only updates follow the [SetSecurityDescriptor contract](https://learn.microsoft.com/en-us/windows/win32/wmisdk/setsecuritydescriptor-method-in-class---systemsecurity).
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index cd1e2f1c..52b9dba0 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,7 +7,7 @@
**改善:**
-- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 (#399) (@Shirofune-Security)
+- ASDのガイドに基づく任意実行のWMI名前空間SACL監査・計画・設定を追加した。ローカル名前空間の明示的な選択と、子名前空間への継承の個別指定に対応する。完全なセキュリティ記述子の記録、SACLだけを更新するネイティブ要求、特権の復元確認、書き込み前の変更検出と読み戻し検証により、既存のアクセス権と未知の監査エントリを保持する。イベント生成と転送の検証は別途必要となる。 使い捨てのServer 2022/2025名前空間でPowerShell 5.1/7の制御フラグ読み戻しと冪等性を検証した。 (#399) (@Shirofune-Security)
- ネイティブのDomain/Private/Publicテキストログを監査・計画・設定する任意実行の`firewall-logging`を追加しました。許可・破棄ログの有効化、最小サイズの確認、既存パスと大きな上限値の保持、CIS v4.0.0のパスの明示的な選択に対応します。ファイアウォールサービスのディレクトリ権限を確認し、ローカル設定と実効設定を記録して変更後の実効設定を検証します。通信制御やACLは変更しません。実通信によるログ生成と収集の検証は別途必要です。 (#394) (@Shirofune-Security)
- イベントログのサイズ監査と設定に共通のバイト単位プロファイルを導入し、AppLocker・ファイアウォールログの256 MiB、Setupの32 MiB、ASD推奨のSecurityログ2048 MiBに対応した。`-LogProfile`と`configure-eventlogs`で送信元と収集サーバーのサイズ・保存方式を選択できる。明示的に指定しない限り、既存の大きいバッファと保存方式は維持する。結果には検証した設定を記録し、未測定の保存日数は不明と表示する。 (#396) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index e929556b..e903dbdb 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,7 +7,7 @@
**Improvements:**
-- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. (#399) (@Shirofune-Security)
+- Added opt-in WMI namespace SACL audit, plan and configure actions based on ASD guidance, with explicit local namespace selection and separate descendant-inheritance consent. Full descriptor journals, SACL-only native requests, checked privilege restoration, race guards and read-back verification preserve existing permissions and unknown audit entries; event generation and forwarding remain separate lab validation. Verified native control-flag readback and idempotence on disposable Server 2022/2025 namespaces under PowerShell 5.1/7. (#399) (@Shirofune-Security)
- Added opt-in `firewall-logging` audit, plan and configure actions for native Domain/Private/Public text logs, with allowed/dropped logging, minimum size checks, preserved operator paths/larger limits, and explicit CIS v4.0.0 paths. Configuration checks firewall service directory permissions, journals local/effective state and verifies effective policy without changing firewall enforcement or ACLs. Traffic and ingestion validation remains required. (#394) (@Shirofune-Security)
- Unified event-log size auditing and configuration with shared byte-based profiles, including 256 MiB AppLocker/firewall logs, 32 MiB Setup and ASD 2048 MiB Security. Added separate source and collector size/mode choices through `-LogProfile` and `configure-eventlogs`; larger buffers and existing retention modes are preserved unless explicitly changed. Results include verified state and unknown retention duration. (#396) (@Shirofune-Security)
From a74a3e79f07a38d91dea06b0fd02d3b4716996eb Mon Sep 17 00:00:00 2001
From: Shirofune-Security
<43838376+Shirofune-Security@users.noreply.github.com>
Date: Sat, 19 Sep 2026 06:25:36 +0900
Subject: [PATCH 13/13] Handle unused AppLocker placeholders without weakening
merge enforcement guards
---
CHANGELOG-Japanese.md | 2 +-
CHANGELOG.md | 2 +-
docs/applocker-readiness.md | 6 ++
scripts/AppLockerReadiness.ps1 | 29 ++++++--
tests/AppLockerReadiness.Tests.ps1 | 78 +++++++++++++++++++++-
tests/AppLockerReadiness.Windows.Tests.ps1 | 15 ++++-
website/docs/resources/changelog.ja.md | 2 +-
website/docs/resources/changelog.md | 2 +-
8 files changed, 124 insertions(+), 12 deletions(-)
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 3445d17c..dabf91fa 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -52,7 +52,7 @@
**新機能:**
-- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否します。未使用の空の NotConfigured コレクションによる誤った比較失敗を防ぎ、新しいルールの対象となる空のコレクションはマージ時に強制が有効になる可能性があるため拒否します。元の XML と未知・設定済みの内容を保持し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fc7e05b9..e970095c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,7 +54,7 @@
**New Features:**
-- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; unused empty NotConfigured placeholders no longer cause false comparison failures, while targeted placeholders remain blocked because merge can retain enforcement. Original XML and unknown/configured collection content stay preserved; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)
diff --git a/docs/applocker-readiness.md b/docs/applocker-readiness.md
index 26589a67..c5f3737b 100644
--- a/docs/applocker-readiness.md
+++ b/docs/applocker-readiness.md
@@ -19,6 +19,10 @@ Import accepts an **operator-supplied** native XML policy. Every included collec
Import only initializes an empty local/GP policy, or verifies an identical previously imported policy. Existing configured collections, existing enforcement (including NotConfigured collections with rules), unreadable policy, domain membership, observed enrollment/provider entries or unknown management state block import. Use the organization's policy authority to manage those hosts. The workflow uses `Set-AppLockerPolicy -Merge`, retains original policy XML in the recovery journal, rechecks state before writing, and verifies local collection content again after writing and at completion. It does not replace an existing policy. An import failure is reported with a nonzero exit code. Dry-run makes no policy or recovery-file changes.
+An omitted **unused** collection and an empty `NotConfigured` placeholder are equivalent for initialization/readback comparisons. A placeholder must have exactly the unqualified `Type` and `EnforcementMode` attributes and no content except whitespace/comments. Empty `Enabled` or `AuditOnly` collections, rules, extensions, unknown attributes/elements/text and namespaces are **not** ignored. Unknown policy-level attributes/content also block import. Raw collections and XML remain in assessments and recovery journals, with `IsEmptyPlaceholder` and `EmptyPlaceholderCount` identifying only the recognized empty shells. Imported collections can therefore be verified alongside unused placeholders without false collection-count failures. Raw XML changes between recovery and the native write still stop the import.
+
+**An empty `NotConfigured` collection targeted for new rules remains a pre-import blocker.** Microsoft documents that a [merge into this shape can retain NotConfigured and start enforcing newly added rules](https://github.com/MicrosoftDocs/memdocs/blob/main/intune/device-configuration/endpoint-security/manage-app-control.md). WELA does not remove that collection, change its mode or assume that a serializer placeholder is safe to merge into. Review it through the existing policy authority before importing. For example, an Exe-only import can coexist with empty Dll/Msi/Script/Appx placeholders, but an existing empty Exe placeholder blocks that import. This precaution is separate from post-import readback, where unused placeholders cannot turn a verified AuditOnly Exe collection into a failure.
+
Microsoft's [Get-AppLockerPolicy documentation](https://learn.microsoft.com/en-us/powershell/module/applocker/get-applockerpolicy) limits that cmdlet to GP policies: **CSP policies are invisible**. Enrollment/provider observations are conservative blockers, not proof that CSP policy is absent. `CspPolicyState=Unknown` remains in every assessment; review other management mechanisms before choosing local import. The [merge semantics](https://learn.microsoft.com/en-us/powershell/module/applocker/set-applockerpolicy) preserve existing enforcement mode. Concurrent policy administration is not an atomic transaction with this workflow; keep the deployment window isolated and review the final readback. No automatic rollback overwrites newer policy.
Recovery: keep the backup directory outside temporary folders. `before.jsonl` contains the original local and GP policy XML, service/channel/management observations and desired policy. The prepared imported XML is retained as `appLocker-audit-import.xml`. Compare them with a fresh audit before recovery; use the existing policy authority or Local Security Policy to remove only the policy created by this run. Do not blindly restore stale effective domain policy or remove someone else's new rules. Use an isolated machine snapshot for integration tests.
@@ -30,3 +34,5 @@ Recovery: keep the backup directory outside temporary folders. `before.jsonl` co
[Microsoft WEF guidance](https://learn.microsoft.com/en-us/windows/security/operating-system-security/device-management/use-windows-event-forwarding-to-assist-in-intrusion-detection) recommends at least an audit-only policy. See Microsoft's [audit-only configuration](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/configure-an-applocker-policy-for-audit-only), [requirements](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/requirements-to-use-applocker) and [rule enforcement behavior](https://learn.microsoft.com/en-us/windows/security/application-security/application-control/app-control-for-business/applocker/working-with-applocker-rules). Native Windows functionality only; Sysmon is out of scope.
Before closing issue #381, on an isolated patched Windows 11/member-server snapshot, export policy/service/channel state, import a reviewed audit-only policy, explicitly configure required service prerequisites, run a benign executable and script, and match the expected AppLocker event XML to their paths/user/rule collection. Confirm an enforced policy stays unchanged when this importer refuses it. Repeat for managed hosts and validate forwarding where required. CI only uses mocked mutations and actual read-only native policy/schema observations; it does not establish event generation or production deployment safety.
+
+Representation regressions cover initialization, readback, idempotence, recovery exports and final drift with empty placeholders. Windows CI additionally reads both equivalent XML shapes through `Test-AppLockerPolicy` without importing them. These tests resolve the collection-count ambiguity; they do not establish how every Windows build serializes a live merge, nor claim that a live policy import or event-generation lab has been performed.
diff --git a/scripts/AppLockerReadiness.ps1 b/scripts/AppLockerReadiness.ps1
index 9efca568..292554ff 100644
--- a/scripts/AppLockerReadiness.ps1
+++ b/scripts/AppLockerReadiness.ps1
@@ -10,6 +10,9 @@ function ConvertFrom-WelaAppLockerXml {
$doc.Load($reader)
} finally { $reader.Dispose() }
if ($doc.DocumentElement.LocalName -cne 'AppLockerPolicy' -or $doc.DocumentElement.NamespaceURI -or $doc.DocumentElement.GetAttribute('Version') -ne '1') { throw 'Expected unqualified AppLockerPolicy Version=1.' }
+ $unknownPolicyData = @($doc.DocumentElement.Attributes | Where-Object { $_.NamespaceURI -or $_.Name -cne 'Version' }).Count -gt 0 -or
+ @($doc.DocumentElement.ChildNodes | Where-Object { $_.NodeType -notin @('Element', 'Whitespace', 'SignificantWhitespace', 'Comment') }).Count -gt 0
+ if ($ForImport -and $unknownPolicyData) { throw 'Unknown policy attributes/content are not accepted for import.' }
$collections = New-Object 'System.Collections.Generic.List[object]'
$types = @{}; $ids = @{}
foreach ($node in @($doc.DocumentElement.ChildNodes | Where-Object NodeType -eq Element)) {
@@ -33,11 +36,18 @@ function ConvertFrom-WelaAppLockerXml {
if (@($rule.ChildNodes | Where-Object { $_.NodeType -eq 'Element' -and $_.LocalName -notin @('Conditions', 'Exceptions') }).Count) { throw 'Unknown rule child element.' }
}
}
- $collections.Add([pscustomobject]@{ Type=$type; EnforcementMode=$mode; RuleCount=$rules.Count; PotentialEnforcement=($mode -eq 'Enabled' -or ($mode -eq 'NotConfigured' -and $rules.Count -gt 0)); Xml=$node.OuterXml })
+ # Some serializers can include empty NotConfigured collection shells.
+ # Only this exact shape is ignorable; zero rules alone is insufficient.
+ $placeholder = $node.LocalName -ceq 'RuleCollection' -and -not $node.NamespaceURI -and
+ $type -cin @('Exe', 'Dll', 'Msi', 'Script', 'Appx') -and $mode -ceq 'NotConfigured' -and
+ $node.Attributes.Count -eq 2 -and
+ @($node.Attributes | Where-Object { $_.NamespaceURI -or $_.Name -cnotin @('Type', 'EnforcementMode') }).Count -eq 0 -and
+ @($node.ChildNodes | Where-Object { $_.NodeType -notin @('Whitespace', 'SignificantWhitespace', 'Comment') }).Count -eq 0
+ $collections.Add([pscustomobject]@{ Type=$type; EnforcementMode=$mode; RuleCount=$rules.Count; IsEmptyPlaceholder=[bool]$placeholder; PotentialEnforcement=($mode -eq 'Enabled' -or ($mode -eq 'NotConfigured' -and $rules.Count -gt 0)); Xml=$node.OuterXml })
}
if ($ForImport -and -not $collections.Count) { throw 'An empty policy cannot supply AppLocker generation prerequisites.' }
if ($ForImport -and @($doc.SelectNodes('//*') | Where-Object { $_.NamespaceURI -or @($_.Attributes | Where-Object { $_.NamespaceURI }).Count }).Count) { throw 'Namespaced policy elements/attributes are not accepted for import.' }
- [pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); TotalRules=(@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
+ [pscustomobject]@{ Xml=$doc.OuterXml; Collections=@($collections.ToArray()); EmptyPlaceholderCount=@($collections.ToArray() | Where-Object IsEmptyPlaceholder).Count; HasUnknownPolicyData=[bool]$unknownPolicyData; TotalRules=(@($collections.ToArray() | Measure-Object RuleCount -Sum)[0].Sum); HasEnforcement=(@($collections.ToArray() | Where-Object PotentialEnforcement).Count -gt 0) }
}
function Get-WelaAppLockerHost {
@@ -136,9 +146,10 @@ function Test-WelaAppLockerPolicyMatch {
param($Snapshot, $Desired)
if ($Snapshot.LocalPolicy.Status -ne 'Observed') { return $false }
$current = $Snapshot.LocalPolicy.Policy
- if ($current.Collections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement) { return $false }
+ $currentCollections = @($current.Collections | Where-Object { -not $_.IsEmptyPlaceholder })
+ if ($currentCollections.Count -ne $Desired.Collections.Count -or $current.HasEnforcement -or $current.HasUnknownPolicyData) { return $false }
foreach ($wanted in $Desired.Collections) {
- $actual = @($current.Collections | Where-Object Type -eq $wanted.Type)
+ $actual = @($currentCollections | Where-Object Type -eq $wanted.Type)
if ($actual.Count -ne 1 -or (Get-WelaAppLockerXmlKey $actual[0].Xml) -cne (Get-WelaAppLockerXmlKey $wanted.Xml)) { return $false }
}
return $true
@@ -150,8 +161,16 @@ function Assert-WelaAppLockerImportSafe {
if ($Snapshot.Host.PartOfDomain -or $Snapshot.Management.Status -ne 'Observed' -or @($Snapshot.Management.ManagementEntries).Count) { throw 'Local import is blocked on domain-joined, managed or unknown-management hosts. Deploy through the existing policy authority.' }
if ($Snapshot.LocalPolicy.Status -ne 'Observed' -or $Snapshot.EffectiveGpPolicy.Status -ne 'Observed') { throw 'Both local and GP effective policies must be readable.' }
if ($Snapshot.LocalPolicy.Policy.HasEnforcement -or $Snapshot.EffectiveGpPolicy.Policy.HasEnforcement) { throw 'Existing enforcement (including NotConfigured collections with rules) is preserved; audit-only import is blocked.' }
+ if ($Snapshot.LocalPolicy.Policy.HasUnknownPolicyData -or $Snapshot.EffectiveGpPolicy.Policy.HasUnknownPolicyData) { throw 'Unknown policy attributes/content are preserved; audit-only import is blocked.' }
if (Test-WelaAppLockerPolicyMatch -Snapshot $Snapshot -Desired $Desired) { return }
- if ($Snapshot.LocalPolicy.Policy.Collections.Count -or $Snapshot.EffectiveGpPolicy.Policy.Collections.Count) { throw 'Existing policy is preserved. Import only initializes an empty local/GP policy; it never replaces a configured policy.' }
+ # -Merge preserves target enforcement settings. A currently empty
+ # NotConfigured target can enforce the new rules once merged. Only unused
+ # placeholders are safe to ignore before an import; do not remove/change them.
+ $targetPlaceholders = @(@($Snapshot.LocalPolicy.Policy.Collections) + @($Snapshot.EffectiveGpPolicy.Policy.Collections) |
+ Where-Object { $_.IsEmptyPlaceholder -and $_.Type -in $Desired.Collections.Type })
+ if ($targetPlaceholders.Count) { throw ('Empty NotConfigured collection(s) targeted by this import are preserved: ' + (($targetPlaceholders.Type | Select-Object -Unique) -join ', ') + '. A merge may retain NotConfigured and enforce newly added rules; review these collections through the existing policy authority before importing.') }
+ if (@($Snapshot.LocalPolicy.Policy.Collections | Where-Object { -not $_.IsEmptyPlaceholder }).Count -or
+ @($Snapshot.EffectiveGpPolicy.Policy.Collections | Where-Object { -not $_.IsEmptyPlaceholder }).Count) { throw 'Existing policy is preserved. Import only initializes an empty local/GP policy; it never replaces a configured policy.' }
}
function New-WelaAppLockerImportReadLock {
diff --git a/tests/AppLockerReadiness.Tests.ps1 b/tests/AppLockerReadiness.Tests.ps1
index 5840b61e..4e8e5d90 100644
--- a/tests/AppLockerReadiness.Tests.ps1
+++ b/tests/AppLockerReadiness.Tests.ps1
@@ -5,6 +5,10 @@ $count=0
function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ }
function Assert-Throws([scriptblock]$Action,$Pattern) { $message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message }; Assert ($message -match $Pattern) "Expected '$Pattern', got '$message'." }
$xml=''
+$placeholderNodes = @('Exe','Dll','Msi','Script','Appx') | ForEach-Object { '' }
+$placeholders = '' + ($placeholderNodes -join '') + ''
+$unusedPlaceholders = '' + (($placeholderNodes | Select-Object -Skip 1) -join '') + ''
+$readbackPlaceholders = $xml.Replace('', (($placeholderNodes | Select-Object -Skip 1) -join '') + '')
$desired=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
Assert ($desired.TotalRules -eq 1 -and -not $desired.HasEnforcement) 'Audit-only rule must parse.'
Assert ((Get-WelaAppLockerXmlKey $xml) -ceq (Get-WelaAppLockerXmlKey ($xml.Replace('Type="Exe" EnforcementMode="AuditOnly"', 'EnforcementMode="AuditOnly" Type="Exe"')))) 'Attribute ordering cannot change compliance.'
@@ -16,11 +20,18 @@ Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace(' '
+Assert ($commented.EmptyPlaceholderCount -eq 1) 'Whitespace and comments do not turn an otherwise empty collection into policy content.'
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml $placeholders -ForImport } 'AuditOnly'
function Reset-Fixture {
$script:localXml=''; $script:effectiveXml=$script:localXml
$script:serviceState='Running'; $script:serviceMode='Auto'; $script:channelEnabled=$true
$script:domain=$false; $script:managed=@(); $script:unknownPolicy=$false; $script:writes=0; $script:readCount=0
$script:race=$false; $script:reject=$false; $script:drift=$false; $script:tamper=$false; $script:validations=0
+ $script:withPlaceholders=$false
}
function Get-WelaAppLockerHost { [pscustomobject]@{Status='Candidate'; Is64BitProcess=$true; PartOfDomain=$script:domain} }
function Get-WelaAppLockerManagement { [pscustomobject]@{Status='Observed'; ManagementEntries=$script:managed; CspPolicyState='Unknown'} }
@@ -52,6 +63,7 @@ function Set-AppLockerPolicy {
$script:writes++
if ($script:reject) { throw 'native rejected policy' }
$script:localXml=[IO.File]::ReadAllText($XmlPolicy); $script:effectiveXml=$script:localXml
+ if ($script:withPlaceholders) { $script:localXml=$readbackPlaceholders; $script:effectiveXml=$script:localXml }
}
Reset-Fixture
$empty=Get-WelaAppLockerReadiness
@@ -74,18 +86,68 @@ Reset-Fixture; $script:managed=@('MDM provider')
Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'managed'
Reset-Fixture; $script:unknownPolicy=$true
Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'readable'
+Reset-Fixture; $script:localXml=$placeholders; $script:effectiveXml=$placeholders
+Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'NotConfigured.*merge may retain'
+Assert (-not (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired)) 'Empty placeholders do not satisfy a requested policy with rules.'
+Reset-Fixture; $script:localXml=$unusedPlaceholders; $script:effectiveXml=$unusedPlaceholders
+Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired
+Assert (-not (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired)) 'Untargeted empty placeholders permit initialization without pretending that requested rules already exist.'
+foreach ($scope in @('Local','Effective')) {
+ Reset-Fixture
+ if ($scope -eq 'Local') { $script:localXml=$placeholders } else { $script:effectiveXml=$placeholders }
+ Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'NotConfigured.*merge may retain'
+}
+Reset-Fixture; $script:localXml=$readbackPlaceholders; $script:effectiveXml=$readbackPlaceholders
+Assert (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired) 'One imported collection plus four empty placeholders matches the requested one-collection policy.'
+Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired
+# A RuleCount == 0 filter would incorrectly ignore each of these. Test both the
+# initial local/effective guards and the post-import comparison against real XML.
+$nonPlaceholders=@(
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ 'unknown content',
+ '',
+ '',
+ '',
+ '',
+ ($desired.Collections[0].Xml.Replace('Type="Exe"','Type="Dll"').Replace('AuditOnly','NotConfigured'))
+)
+foreach ($node in $nonPlaceholders) {
+ $policyXml='' + $node + ''
+ $parsed=ConvertFrom-WelaAppLockerXml -Xml $policyXml
+ Assert ($parsed.EmptyPlaceholderCount -eq 0 -and -not $parsed.Collections[0].IsEmptyPlaceholder) 'Configured/unknown collection content is never normalized away.'
+ foreach ($scope in @('Local','Effective')) {
+ Reset-Fixture
+ if ($scope -eq 'Local') { $script:localXml=$policyXml } else { $script:effectiveXml=$policyXml }
+ Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'preserved'
+ }
+ Reset-Fixture; $script:localXml=$xml.Replace('', $node + '')
+ Assert (-not (Test-WelaAppLockerPolicyMatch (Get-WelaAppLockerReadiness) $desired)) 'Unexpected configured/unknown collection fails readback even when the requested Exe rule matches.'
+}
+foreach ($policyXml in @($placeholders.Replace('Version="1"','Version="1" Future=""'), $placeholders.Replace('','unknown content'))) {
+ Reset-Fixture; $script:localXml=$policyXml
+ Assert-Throws { Assert-WelaAppLockerImportSafe (Get-WelaAppLockerReadiness) $desired } 'Unknown policy'
+}
+Assert-Throws { ConvertFrom-WelaAppLockerXml -Xml ($xml.Replace('Version="1"','Version="1" Future=""')) -ForImport } 'Unknown policy'
$cleanup=@()
try {
- foreach ($scenario in @('apply','dry','race','failure','drift','existing','tamper')) {
+ foreach ($scenario in @('apply','dry','race','failure','drift','existing','tamper','placeholders','placeholder-dry','placeholder-drift','target-placeholder')) {
Reset-Fixture
+ if ($scenario -like 'placeholder*') { $script:localXml=$unusedPlaceholders; $script:effectiveXml=$unusedPlaceholders; $script:withPlaceholders=$true }
+ if ($scenario -eq 'target-placeholder') { $script:localXml=$placeholders; $script:effectiveXml=$placeholders }
if ($scenario -eq 'race') {$script:race=$true}
if ($scenario -eq 'tamper') {$script:tamper=$true}
if ($scenario -eq 'failure') {$script:reject=$true}
if ($scenario -eq 'drift') {$script:drift=$true}
if ($scenario -eq 'existing') {$script:localXml=$xml;$script:effectiveXml=$xml}
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-'+[guid]::NewGuid().ToString('N'));$cleanup+=$path
- $context=New-WelaConfigurationContext -Auto -DryRun:($scenario -eq 'dry') -BackupPath $path
+ $context=New-WelaConfigurationContext -Auto -DryRun:($scenario -in @('dry','placeholder-dry')) -BackupPath $path
Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ if ($scenario -eq 'placeholder-drift') { $script:localXml=$readbackPlaceholders.Replace('Type="Dll" EnforcementMode="NotConfigured"','Type="Dll" EnforcementMode="AuditOnly"') }
$result=Complete-WelaConfiguration -Context $context
switch ($scenario) {
'apply' {
@@ -100,6 +162,18 @@ try {
'failure' { Assert ($result.ExitCode -eq 1) 'Native write failure must propagate.' }
'drift' { Assert ($result.ExitCode -eq 1) 'Final readback must detect policy drift.' }
'existing' { Assert ($script:writes -eq 0 -and $result.ExitCode -eq 0) 'Identical policy stays unchanged.' }
+ 'placeholders' {
+ Assert ($script:writes -eq 1 -and $result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Public runner imports from empty placeholders and verifies populated readback with remaining placeholders.'
+ $journal=Get-Content (Join-Path $path 'before.jsonl') | ConvertFrom-Json
+ Assert ($journal.Before.LocalPolicy.Policy.Collections.Count -eq 4 -and $journal.Before.LocalPolicy.Policy.EmptyPlaceholderCount -eq 4) 'Recovery journal preserves all original unused placeholder collection metadata.'
+ $export=$result | ConvertTo-Json -Depth 20 | ConvertFrom-Json
+ Assert ($export.Results[0].After.LocalPolicy.Policy.Collections.Count -eq 5 -and $export.Results[0].After.LocalPolicy.Policy.EmptyPlaceholderCount -eq 4) 'Result JSON distinguishes the configured collection from four retained placeholders.'
+ Set-WelaAppLockerAuditPolicy -Context $context -Desired $desired
+ Assert ($script:writes -eq 1 -and $context.Results[1].Status -eq 'AlreadyCompliant') 'Repeated import with placeholders performs no duplicate merge.'
+ }
+ 'placeholder-dry' { Assert ($script:writes -eq 0 -and -not (Test-Path $path) -and $result.Results[0].Status -eq 'Skipped') 'Placeholder normalization does not weaken dry-run guarantees.' }
+ 'placeholder-drift' { Assert ($script:writes -eq 1 -and $result.ExitCode -eq 1 -and $result.Results[0].Status -in @('Failed','Overridden')) 'Final drift from an empty placeholder into a configured empty collection remains a failure.' }
+ 'target-placeholder' { Assert ($script:writes -eq 0 -and $script:validations -eq 0 -and $result.ExitCode -eq 1 -and $result.Results[0].Diagnostic -match 'merge may retain NotConfigured') 'A targeted empty NotConfigured collection blocks before native import to avoid accidental enforcement.' }
}
}
$path=Join-Path ([IO.Path]::GetTempPath()) ('wela-applocker-existing-'+[guid]::NewGuid().ToString('N')+'.xml');$cleanup+=$path
diff --git a/tests/AppLockerReadiness.Windows.Tests.ps1 b/tests/AppLockerReadiness.Windows.Tests.ps1
index 8a1ca882..1feaa28c 100644
--- a/tests/AppLockerReadiness.Windows.Tests.ps1
+++ b/tests/AppLockerReadiness.Windows.Tests.ps1
@@ -11,6 +11,7 @@ foreach ($scope in @($report.LocalPolicy,$report.EffectiveGpPolicy)) {
# The native cmdlet parses the XML without installing it or executing the file.
if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
$path=Join-Path $env:TEMP ('wela-applocker-schema-'+[guid]::NewGuid().ToString('N')+'.xml')
+ $placeholderPath=$path.Replace('.xml','-placeholders.xml')
try {
$xml=''
$policy=ConvertFrom-WelaAppLockerXml -Xml $xml -ForImport
@@ -19,6 +20,18 @@ if (Get-Command Test-AppLockerPolicy -ErrorAction SilentlyContinue) {
$validation=@(Test-AppLockerPolicy -XmlPolicy $path -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
if (-not $validation.Count) { throw 'Native schema validation returned no decision.' }
} finally { $lock.Dispose() }
- } finally { Remove-Item -LiteralPath $path -Force -ErrorAction SilentlyContinue }
+ # In-memory native-readback representation: the one Exe collection plus
+ # empty NotConfigured shells for other types. Validate through the native
+ # reader only; this does not install or merge any policy.
+ $shells=(@('Dll','Msi','Script','Appx') | ForEach-Object { '' }) -join ''
+ $withPlaceholders=ConvertFrom-WelaAppLockerXml -Xml $policy.Xml.Replace('',$shells+'')
+ $snapshot=[pscustomobject]@{LocalPolicy=[pscustomobject]@{Status='Observed';Policy=$withPlaceholders}}
+ if (-not (Test-WelaAppLockerPolicyMatch $snapshot $policy) -or $withPlaceholders.EmptyPlaceholderCount -ne 4) { throw 'Placeholder readback representation did not match the requested collection.' }
+ $lock=New-WelaAppLockerImportReadLock -Path $placeholderPath -Xml $withPlaceholders.Xml
+ try {
+ $withShells=@(Test-AppLockerPolicy -XmlPolicy $placeholderPath -Path "$env:SystemRoot\System32\cmd.exe" -User 'S-1-1-0' -ErrorAction Stop)
+ if ($withShells.Count -ne $validation.Count -or (($withShells.PolicyDecision -join ',') -cne ($validation.PolicyDecision -join ','))) { throw 'Native XML reader changed its decision with empty NotConfigured placeholders.' }
+ } finally { $lock.Dispose() }
+ } finally { Remove-Item -LiteralPath $path,$placeholderPath -Force -ErrorAction SilentlyContinue }
} else { Write-Host 'Native policy validation unavailable in this PowerShell session; importer will refuse.' }
Write-Host 'PASS: native read-only AppLocker observations. No Set-AppLockerPolicy or service changes.'
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index 6146fd3f..b3365dc7 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -55,7 +55,7 @@
**新機能:**
-- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
+- `applocker-readiness` を追加し、AppLocker のポリシー、強制モード、Application Identity サービス、チャネルを確認できるようにしました。空のローカルポリシーには指定した監査専用 XML を検証してインポートできます。既存の強制ポリシーや管理対象ホストでは変更を拒否します。未使用の空の NotConfigured コレクションによる誤った比較失敗を防ぎ、新しいルールの対象となる空のコレクションはマージ時に強制が有効になる可能性があるため拒否します。元の XML と未知・設定済みの内容を保持し、CSP とイベント生成の未検証状態を明示します。 (#400) (@Shirofune-Security)
- MITRE ATT&CK Navigatorヒートマップに対応した。 (#11) (@fukusuket)
- Windows設定を様々なベースラインに構成するための`configure`コマンドを追加した。 (#12) (@fukusuket)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index f4f3f065..f22e2b08 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -57,7 +57,7 @@
**New Features:**
-- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
+- Added `applocker-readiness` to inspect native policy collections, enforcement, Application Identity and channels, plus a guarded operator-supplied audit-only import for empty local policies. Existing enforcement and managed hosts block import; unused empty NotConfigured placeholders no longer cause false comparison failures, while targeted placeholders remain blocked because merge can retain enforcement. Original XML and unknown/configured collection content stay preserved; GP/CSP visibility and event-generation gaps remain explicit. (#400) (@Shirofune-Security)
- Support for MITRE ATT&CK Navigator heatmaps. (#11) (@fukusuket)
- Added a `configure` command to configure Windows settings to various baselines. (#12) (@fukusuket)