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)