diff --git a/.gitattributes b/.gitattributes index 00a732d1..c373aafb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -122,6 +122,11 @@ tests/FileSaclLifecycle.Windows.Tests.ps1 text eol=lf /scripts/ProcessCommandline.ps1 text eol=lf /tests/ProcessCommandline* text eol=lf +# Native WMI tree evidence binds source bytes across checkouts. +/scripts/WmiNamespaceAuditing.ps1 text eol=lf +/scripts/WmiNamespaceDescendants.ps1 text eol=lf +/tests/WmiNamespaceDescendants* text eol=lf +/WELA.ps1 text eol=lf # Reviewed WMI namespace recovery binds exact source bytes. /scripts/WmiSaclRecovery.ps1 text eol=lf /tests/WmiSaclRecovery* text eol=lf diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 01cb9ca0..e7cd473e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -41,7 +41,7 @@ jobs: Copy-Item -Recurse -Path ./scripts -Destination release-binaries/ Copy-Item -Recurse -Path ./modules -Destination release-binaries/ New-Item -ItemType Directory -Path release-binaries/docs -Force | Out-Null - Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/process-commandline.md, ./docs/transcript-probe.md, ./docs/file-sacl-recovery.md, ./docs/dns-client-probe.md, ./docs/wmi-sacl-recovery.md -Destination release-binaries/docs/ + Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md, ./docs/wmi-probe.md, ./docs/firewall-logging.md, ./docs/firewall-logging-recovery.md, ./docs/ipsec-prerequisites.md, ./docs/wec-state.md, ./docs/wec-update.md, ./docs/wec-runtime.md, ./docs/wef-deployment.md, ./docs/native-channel-access.md, ./docs/eventlog-settings.md, ./docs/channel-read.md, ./docs/native-rule-eligibility.md, ./docs/native-validation.md, ./docs/wef-arrival.md, ./docs/smb-runtime-activation.md, ./docs/smb-auditing.md, ./docs/wec-ingress.md, ./docs/capi2-probe.md, ./docs/powershell-transcription.md, ./docs/transcription-recovery.md, ./docs/eventlog-recovery.md, ./docs/failed-logon-probe.md, ./docs/wec-listener.md, ./docs/file-access-probe.md, ./docs/applocker-script-probe.md, ./docs/applocker-probe.md, ./docs/selected-sacl-configuration.md, ./docs/registry-sacl-recovery.md, ./docs/targeted-sacl-planning.md, ./docs/native-registry-sacl-validation.md, ./docs/wec-authorization.md, ./docs/channel-recovery.md, ./docs/outgoing-ntlm.md, ./docs/wec-collector-observation.md, ./docs/native-provider-packs.md, ./docs/native-provider-acceptance.md, ./docs/audit-catalog-mappings.md, ./docs/native-token-right-attribution.md, ./docs/audit-notifications.md, ./docs/native-onesettings-acceptance.md, ./docs/ntlm-auditing.md, ./docs/wef-query.md, ./docs/native-filesystem-sacl-validation.md, ./docs/process-commandline.md, ./docs/transcript-probe.md, ./docs/file-sacl-recovery.md, ./docs/dns-client-probe.md, ./docs/wmi-sacl-recovery.md, ./docs/wmi-namespace-auditing.md, ./docs/wmi-descendants.md -Destination release-binaries/docs/ - name: Set Artifact Name if: contains(matrix.info.os, 'windows') == true diff --git a/.github/workflows/wmi-descendants.yml b/.github/workflows/wmi-descendants.yml new file mode 100644 index 00000000..ec11f62c --- /dev/null +++ b/.github/workflows/wmi-descendants.yml @@ -0,0 +1,49 @@ +name: Native WMI descendant safeguards +on: + push: + branches: ['**'] + paths: + - 'scripts/WmiNamespaceAuditing.ps1' + - 'scripts/WmiNamespaceDescendants.ps1' + - 'tests/WmiNamespaceDescendants*' + - '.github/workflows/wmi-descendants.yml' + pull_request: + workflow_dispatch: +permissions: + contents: read +jobs: + wmi-descendants: + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + os: [windows-2022, windows-2025] + shell: [powershell, pwsh] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Native tree validation in Windows PowerShell + if: matrix.shell == 'powershell' + shell: powershell + run: | + ./tests/WmiNamespaceAuditing.Tests.ps1 + ./tests/WmiNamespaceDescendants.Tests.ps1 + ./tests/WmiNamespaceDescendants.Cli.Tests.ps1 + ./tests/WmiNamespaceDescendants.Windows.Tests.ps1 -AllowDisposableNamespaceWrite -EvidencePath wmi-descendants-native.json + - name: Native tree validation in PowerShell 7 + if: matrix.shell == 'pwsh' + shell: pwsh + run: | + ./tests/WmiNamespaceAuditing.Tests.ps1 + ./tests/WmiNamespaceDescendants.Tests.ps1 + ./tests/WmiNamespaceDescendants.Cli.Tests.ps1 + ./tests/WmiNamespaceDescendants.Windows.Tests.ps1 -AllowDisposableNamespaceWrite -EvidencePath wmi-descendants-native.json + - name: Retain complete native observations + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: wmi-descendants-${{ matrix.os }}-${{ matrix.shell }} + path: | + wmi-descendants-native.json + wmi-descendants-native.json.journals + if-no-files-found: error diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md index 5913f884..1906a225 100644 --- a/CHANGELOG-Japanese.md +++ b/CHANGELOG-Japanese.md @@ -8,6 +8,8 @@ - 組み込みの Security 4688 コマンドライン記録ポリシーのみを扱う `process-commandline` の Audit/Plan/Configure を追加しました。変更前の型付き状態、競合検出、監査の前提条件の分離、Windows 上の設定とイベント検証に対応します。#364、#365、#387 に関連します。 +- WMI の明示的な継承設定に、既存の子名前空間の上限付き調査、完全な変更前記録、ツリー変更の拒否を追加しました。親の SACL のみを書き込み、継承・保護状態と最終状態を確認します。不完全な伝播は失敗として扱い、子への直接書き込み、自動復元、イベントや Sigma 対応を保証しません。無関係な引数や余分な位置引数も拒否します。(Related #372) (@Shirofune-Security) + - 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security) - 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security) diff --git a/CHANGELOG.md b/CHANGELOG.md index f9574535..68d656ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ - Added `process-commandline` Audit/Plan/Configure for only the built-in Security 4688 command-line policy, with typed originals, drift checks, separate audit prerequisites and native Windows configuration/event validation. Related to #364, #365 and #387. +- Added bounded existing-descendant snapshots, full recovery evidence and stale-tree guards for explicit WMI inheritance. Parent-only SACL writes now require native inherited/protected readbacks and final drift checks; incomplete propagation fails without child setters, rollback ownership, event or Sigma credit. Unrelated or extra positional WMI command arguments are now refused. (Related #372) (@Shirofune-Security) + - Add reviewed `wmi-sacl-recovery` Plan/Recover for one proven explicit parent-only namespace audit ACE. Require matching completed configuration evidence, current full descriptor, reviewed hash and explicit audit-reduction consent; preserve all other descriptor fields, remaining ACEs and token privileges, retain partial-write evidence, and test owned native namespace cleanup. Historical namespace/operator identity and event/Sigma readiness remain unclaimed. (Related #372, #365) (@Shirofune-Security) - Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security) diff --git a/WELA.ps1 b/WELA.ps1 index 1386d0c6..b79eca81 100644 --- a/WELA.ps1 +++ b/WELA.ps1 @@ -2357,6 +2357,9 @@ if ($DryRun -and -not ($Cmd -eq 'file-sacl-recovery' -and $FileSaclRecoveryActio 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 ($Cmd -eq 'wmi-auditing' -and ($args.Count -or @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','WmiAction','WmiNamespace','WmiIncludeChildren','Auto','DryRun','BackupPath','ResultsPath','Help')}).Count)) { + throw 'wmi-auditing accepts only its dedicated namespace options, Auto, DryRun, BackupPath, ResultsPath and Help. Unexpected positional or unrelated arguments are refused.' +} 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.' } @@ -2767,7 +2770,7 @@ 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.' + Write-Host 'Select exact local namespaces explicitly. Default action List is read-only. Configure appends ASD success audit ACEs; WmiIncludeChildren requires complete stable descendant snapshots and inherited/protected readbacks. Unverified propagation fails even if the parent write succeeded; no child setter or automatic rollback. See docs/wmi-descendants.md.' return } if ($Profile -or $Baseline) { throw 'wmi-auditing uses its own namespace selections, not -Profile or -Baseline.' } diff --git a/docs/wmi-descendants.md b/docs/wmi-descendants.md new file mode 100644 index 00000000..1f11972f --- /dev/null +++ b/docs/wmi-descendants.md @@ -0,0 +1,23 @@ +# Reviewed WMI namespace descendants + +`wmi-auditing -WmiIncludeChildren` now inventories existing descendants before an inheritable parent SACL write. It still accepts only the five reviewed local catalog namespaces. The CIMV2 definitions use parent-only flags even when this option is selected; the four reference definitions with flag66 receive the extra safeguards. No descendant is passed to a setter. + +```powershell +./WELA.ps1 wmi-auditing -WmiAction Plan -WmiNamespace 'root\default' -WmiIncludeChildren -ResultsPath tree-plan.json +./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace 'root\default' -WmiIncludeChildren -DryRun -ResultsPath tree-dry-run.json +./WELA.ps1 wmi-auditing -WmiAction Configure -WmiNamespace 'root\default' -WmiIncludeChildren -BackupPath C:\Evidence\new-wmi-backup -ResultsPath tree-result.json +``` + +Review `Controls[].Descendants` and the full descriptor strings before configuration. Configure builds a fresh in-memory plan, shows the descendant count in its confirmation, and checks that same tree again before writing. The earlier Plan export is documentation of its observation, not a persisted authorization token consumed by Configure. `-Auto` skips the confirmation only; it does not skip the tree checks. The CLI refuses unrelated parameters and extra positional arguments instead of silently binding them to an unused output-format parameter. + +The inventory records every existing child and grandchild within 64 descendants, eight levels and two MiB of descriptor evidence. Two complete passes must agree on names, parent relationships and every full descriptor. Unknown names, duplicates, access failures, caps, incomplete reads and drift fail closed. The scan checks a 30-second budget between namespaces, and native enumeration requests a ten-second timeout. Individual synchronous provider calls cannot be forcibly cancelled, so this is not a hard total runtime limit. Winmgmt must already be running. Host, implementation fingerprints and the full observed caller SID, logon, groups and privilege attributes must remain unchanged. + +`before.jsonl` retains the existing parent `DescriptorJson` and `DescriptorMof` fields and adds complete descendant descriptor strings. After confirmation and journaling, another stable inventory must match before the parent-only SACL setter is reached. A journal or guard failure prevents that setter. Original parent ACEs and owner/group/DACL continue to use the existing preservation contract. + +After a write, the command requires the same existing descendants, preserves every unrelated descriptor field and original ACE multiplicity, and accepts only the exact requested inherited success ACEs. A returned `SE_SACL_PROTECTED` bit is treated conservatively as a preservation barrier for that namespace and its descendants: the entire observed descriptor must stay unchanged. This does not establish that every WMI provider enforces that bit. Missing inherited entries, an unexpected ACE, changed protection or a new/disappearing namespace is unverified and causes a nonzero result. A final full tree read checks for later drift. Results include the individual descendant observations and diagnostics. + +Microsoft documents [namespace inheritance when a child is created](https://learn.microsoft.com/en-us/windows/win32/wmisdk/establishing-inheritance-of-namespace-security) and the [inherited ACE flag](https://learn.microsoft.com/en-us/windows/win32/wmisdk/namespace-ace-flag-constants). This does not establish that adding an inheritable ACE retroactively updates every existing child. A successful parent setter can therefore be followed by a failed descendant verification. The parent addition may remain in place; the command does not retry child writes or claim subtree success. A repeat also fails when the parent is already compliant but existing descendants lack the requested inherited entry. Inspect the recorded native outcomes before choosing a separately reviewed child configuration or recovery procedure. + +There is no transaction across the tree. A concurrent change between the final pre-write observations and the provider call remains possible. Namespace names do not establish durable identity across deletion/recreation. No automatic rollback, descendant ACE ownership, future-child behavior, event generation, remote WMI, forwarding or Sigma readiness is inferred. [SetSecurityDescriptor's SACL-only contract](https://learn.microsoft.com/en-us/windows/win32/wmisdk/setsecuritydescriptor-method-in-class---systemsecurity) preserves owner, group and DACL by omitting those fields from the request. + +Native acceptance uses generated `root\WelaInheritance_` namespaces on disposable Server2022/2025 hosts, runs the production inventory/configuration functions, records exact provider outcomes and removes only owned instances in reverse creation order. It does not redirect the production catalog or write existing production namespaces. Synthetic tests separately exercise caps, stale descriptors, protected subtrees, missing inheritance, unexpected child changes and final failure propagation. Windows11, production target writes, DC/ADCS role behavior and forwarding remain unverified. diff --git a/docs/wmi-namespace-auditing.md b/docs/wmi-namespace-auditing.md index a92fc88b..e572a6c6 100644 --- a/docs/wmi-namespace-auditing.md +++ b/docs/wmi-namespace-auditing.md @@ -29,7 +29,7 @@ The entries come from the [ASD WMI script pinned at 59041b5](https://github.com/ | `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. +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. Existing descendants are now enumerated, journaled and checked before and after the parent-only setter; incomplete inventories or unverified inheritance fail the operation. Review the [bounded descendant safeguards](wmi-descendants.md), including provider limitations and possible parent-only partial outcomes. 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 @@ -51,9 +51,9 @@ WELA separately observes the effective **Other Object Access Events** audit poli ## 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. +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, retain the descendant snapshots and inspect each reported outcome; these observations confer no descendant ACE ownership or automatic rollback authority. 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. 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. +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 and forwarding have not been verified by these tests.** A separate [descendant acceptance fixture](wmi-descendants.md) records native existing-child outcomes and owned new-child inheritance without extrapolating production-tree behavior. 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/WmiNamespaceAuditing.ps1 b/scripts/WmiNamespaceAuditing.ps1 index 1c25a37b..11afcffd 100644 --- a/scripts/WmiNamespaceAuditing.ps1 +++ b/scripts/WmiNamespaceAuditing.ps1 @@ -1,4 +1,5 @@ # Opt-in local namespace SACLs. No namespace DACL, audit policy, or remote-access changes. +. (Join-Path $PSScriptRoot 'WmiNamespaceDescendants.ps1') function Get-WelaWmiAuditDefinitions { param([string[]]$Namespace, [switch]$IncludeChildren) $source = 'https://github.com/AustralianCyberSecurityCentre/windows_event_logging/blob/59041b5d4586789a751171fb752be1624ad5e3b4/events/wmi_auditing/wmi_auditing.ps1' @@ -256,10 +257,19 @@ function Get-WelaWmiAuditPlan { foreach ($name in @($definitions | Select-Object -ExpandProperty Namespace -Unique)) { $selected = @($definitions | Where-Object Namespace -eq $name) try { - $snapshot = Get-WelaWmiNamespaceSnapshot $name + $tree=$null + if(@($selected|Where-Object {($_.AceFlags -band 2) -ne 0}).Count){ + $tree=Get-WelaWmiStableDescendants $name + $snapshot=$tree.Root + } else {$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 = '' } + $status=if($missing.Count){'ChangeRequired'}else{'AlreadyCompliant'};$diagnostic='' + if($tree -and -not $missing.Count){ + $outcomes=Test-WelaWmiDescendantOutcomes $tree $tree $selected + if($outcomes.Status -cne 'Observed'){$status='Unknown';$diagnostic='Parent entry exists but descendants are unverified: '+($outcomes.Diagnostics -join '; ')} + } + [pscustomobject]@{ Namespace = $name; Status = $status; Before = $snapshot; Definitions = $selected; Missing = $missing; Descendants=$tree; Diagnostic = $diagnostic } } catch { [pscustomobject]@{ Namespace = $name; Status = 'Unknown'; Before = $null; Definitions = $selected; Missing = @(); Diagnostic = $_.Exception.Message } } } } @@ -267,16 +277,35 @@ function Get-WelaWmiAuditPlan { 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 } + $inherit=@($entry.Definitions|Where-Object {($_.AceFlags -band 2) -ne 0}).Count -gt 0 + $callback = @{ Namespace = $entry.Namespace; Definitions = $entry.Definitions; Original = $null; ExpectedJson = $null; Applied = $false; VerifiedJson = $null; Inherit=$inherit; PlannedTree=$entry.Descendants; OriginalTree=$null; VerifiedTree=$null; DescendantVerification=[pscustomobject]@{ParentSetterAttempted=$false;ParentSetterAccepted=$false;Observation=$null;LastTree=$null} } $read = { param($state) - $snapshot = Get-WelaWmiNamespaceSnapshot $state.Namespace + if($state.Inherit){ + $tree=Get-WelaWmiStableDescendants $state.Namespace $state.DescendantVerification + if($null -eq $state.OriginalTree){ + if((Get-WelaWmiDescendantKey $tree) -cne (Get-WelaWmiDescendantKey $state.PlannedTree)){throw 'WMI descendant tree changed after planning; no SACL was written.'} + $state.OriginalTree=$tree + } + if($tree.Context -cne $state.OriginalTree.Context){throw 'Caller token, host, source or service context changed since descendant planning.'} + $snapshot=$tree.Root|Select-Object * + $snapshot|Add-Member NoteProperty Descendants $tree -Force + } else {$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($state.Inherit){ + $state.DescendantVerification.Observation=Test-WelaWmiDescendantOutcomes $state.OriginalTree $snapshot.Descendants $state.Definitions + if($state.Applied -or -not @(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count){ + if($state.DescendantVerification.Observation.Status -cne 'Observed'){throw ('WMI descendant outcome is unverified: '+($state.DescendantVerification.Observation.Diagnostics -join '; '))} + $key=Get-WelaWmiDescendantKey $snapshot.Descendants + if($null -eq $state.VerifiedTree){$state.VerifiedTree=$key} + if($key -cne $state.VerifiedTree){throw 'WMI descendant descriptor changed after verification.'} + } + } if (@(Get-WelaWmiMissingAces $descriptor $state.Definitions).Count) { return $false } if ($state.Applied) { if (-not (Test-WelaWmiDescriptorPreserved $state.Original $descriptor)) { return $false } @@ -288,13 +317,21 @@ function Set-WelaWmiAuditControls { } $apply = { param($state) + if($state.Inherit){ + $fresh=Get-WelaWmiStableDescendants $state.Namespace $state.DescendantVerification + if((Get-WelaWmiDescendantKey $fresh) -cne (Get-WelaWmiDescendantKey $state.OriginalTree)){throw 'WMI descendant topology or descriptor changed before the parent setter; no SACL was written.'} + $state.DescendantVerification.ParentSetterAttempted=$true + $state.DescendantVerification.Observation=$null + } Set-WelaWmiNamespaceDescriptor -Namespace $state.Namespace -ExpectedJson $state.ExpectedJson -Definitions $state.Definitions + if($state.Inherit){$state.DescendantVerification.ParentSetterAccepted=$true} $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 ', ')) + -Description ('Append missing success audit ACEs. Scope: ' + (($entry.Definitions.Scope | Select-Object -Unique) -join ', ') + $(if($inherit){'; reviewed existing descendants: '+@($entry.Descendants.Entries).Count+'. Only the parent is written; unsupported propagation fails verification.'}else{''})) + if($inherit){$Context.Results[$Context.Results.Count-1]|Add-Member NoteProperty DescendantVerification $callback.DescendantVerification} } } diff --git a/scripts/WmiNamespaceDescendants.ps1 b/scripts/WmiNamespaceDescendants.ps1 new file mode 100644 index 00000000..f7efeda1 --- /dev/null +++ b/scripts/WmiNamespaceDescendants.ps1 @@ -0,0 +1,144 @@ +# Read-only bounded observations. A descendant is never passed to a setter. +function Get-WelaWmiDescendantContext { + if(-not (Get-Command Initialize-WelaWmiProbeNative -ErrorAction SilentlyContinue)){. (Join-Path $PSScriptRoot 'WmiProbe.ps1')} + Initialize-WelaWmiProbeNative + if((Get-Service Winmgmt -ErrorAction Stop).Status -ne 'Running'){throw 'Winmgmt must already be running before descendant observation.'} + $sources=[ordered]@{} + foreach($name in @('WmiNamespaceAuditing.ps1','WmiNamespaceDescendants.ps1','WmiProbeNative.cs','WmiProbe.ps1','Configuration.ps1')){$sources[$name]=(Get-FileHash -LiteralPath (Join-Path $PSScriptRoot $name) -Algorithm SHA256 -ErrorAction Stop).Hash} + [ordered]@{Computer=[Environment]::MachineName;Version=[Environment]::OSVersion.VersionString;Token=(Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()));Sources=$sources}|ConvertTo-Json -Compress -Depth 5 +} +function Get-WelaWmiChildNames { + param([string]$Namespace,[int]$Maximum) + Initialize-WelaWmiInterop + $options=New-Object System.Management.ConnectionOptions + $options.EnablePrivileges=$false;$options.Impersonation=[System.Management.ImpersonationLevel]::Impersonate + $scope=New-Object System.Management.ManagementScope -ArgumentList "\\.\$Namespace",$options + $query=New-Object System.Management.ObjectQuery -ArgumentList 'SELECT Name FROM __Namespace' + $enumeration=New-Object System.Management.EnumerationOptions + $enumeration.ReturnImmediately=$true;$enumeration.Rewindable=$false;$enumeration.BlockSize=1 + $enumeration.Timeout=[TimeSpan]::FromSeconds(10) + $searcher=New-Object System.Management.ManagementObjectSearcher -ArgumentList $scope,$query,$enumeration + $collection=$null;$names=New-Object 'System.Collections.Generic.List[string]' + try { + $collection=$searcher.Get() + foreach($item in $collection){ + try { + if($names.Count -ge $Maximum){throw 'WMI descendant count exceeds the reviewed maximum of 64.'} + $name=$item.Name + if($name -isnot [string] -or $name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$'){throw 'Unsupported or ambiguous native child namespace name.'} + $names.Add($name) + } finally {$item.Dispose()} + } + @($names.ToArray()|Sort-Object) + } finally {if($collection){$collection.Dispose()};$searcher.Dispose()} +} +function Get-WelaWmiDescendantKey { + param($Tree) + if($null -eq $Tree -or $Tree.Status -cne 'Complete' -or $Tree.Maximum -ne 64 -or $Tree.MaximumDepth -ne 8 -or $Tree.Entries -isnot [array] -or $Tree.Entries.Count -gt 64){throw 'Complete bounded WMI descendant evidence is required.'} + $parts=@($Tree.Context,$Tree.Root.Namespace,$Tree.Root.DescriptorJson) + foreach($entry in $Tree.Entries){ + if($entry.Namespace -cne $entry.Snapshot.Namespace -or $entry.Depth -lt 1 -or $entry.Depth -gt 8 -or $entry.ProtectedBarrier -isnot [bool]){throw 'Malformed WMI descendant evidence.'} + $parts+=@($entry.Namespace,$entry.Parent,[string]$entry.Depth,[string]$entry.ProtectedBarrier,$entry.Snapshot.DescriptorJson) + } + ConvertTo-Json -InputObject $parts -Compress -Depth 4 +} +function Get-WelaWmiDescendants { + param([string]$Namespace) + $entries=New-Object 'System.Collections.Generic.List[object]' + $diagnostics=New-Object 'System.Collections.Generic.List[string]' + $queue=New-Object 'System.Collections.Generic.Queue[object]' + $seen=New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase) + $root=$null;$started=[DateTime]::UtcNow;$bytes=0 + try { + if($Namespace -cnotmatch '^root(\\[A-Za-z_][A-Za-z0-9_]{0,63}){1,8}$'){throw 'An exact local WMI namespace is required.'} + $root=Get-WelaWmiNamespaceSnapshot $Namespace + if($root.Namespace -cne $Namespace -or -not $root.DescriptorJson -or -not $root.DescriptorMof){throw 'Incomplete selected namespace descriptor.'} + $bytes=[Text.Encoding]::UTF8.GetByteCount($root.DescriptorJson+$root.DescriptorMof) + if($bytes -gt 2097152){throw 'WMI tree descriptor evidence exceeds two MiB.'} + $queue.Enqueue([pscustomobject]@{Namespace=$Namespace;Depth=0;ProtectedBarrier=$false}) + $null=$seen.Add($Namespace) + while($queue.Count){ + if(([DateTime]::UtcNow-$started).TotalSeconds -gt 30){throw 'WMI tree scan time budget exceeded; individual provider calls are not forcibly cancellable.'} + $parent=$queue.Dequeue() + $children=@(Get-WelaWmiChildNames $parent.Namespace (64-$entries.Count)) + if($entries.Count+$children.Count -gt 64){throw 'WMI descendant count exceeds 64.'} + if($parent.Depth -ge 8 -and $children.Count){throw 'WMI descendant depth exceeds eight.'} + foreach($name in $children){ + if($name -isnot [string] -or $name -cnotmatch '^[A-Za-z_][A-Za-z0-9_]{0,63}$'){throw 'Unsupported native child namespace name.'} + $path=$parent.Namespace+'\'+$name + if(-not $seen.Add($path)){throw 'Duplicate or ambiguous WMI namespace during enumeration.'} + $snapshot=Get-WelaWmiNamespaceSnapshot $path + if($snapshot.Namespace -cne $path -or -not $snapshot.DescriptorMof){throw 'Incomplete or mismatched namespace descriptor.'} + $bytes+=[Text.Encoding]::UTF8.GetByteCount($snapshot.DescriptorJson+$snapshot.DescriptorMof) + if($bytes -gt 2097152){throw 'WMI descendant evidence exceeds two MiB.'} + $descriptor=$snapshot.DescriptorJson|ConvertFrom-Json -ErrorAction Stop + if($null -eq $descriptor.ControlFlags){throw 'Incomplete child descriptor controls.'} + $barrier=$parent.ProtectedBarrier -or (([uint32]$descriptor.ControlFlags -band 8192) -ne 0) + $entry=[pscustomobject]@{Namespace=$path;Parent=$parent.Namespace;Depth=$parent.Depth+1;ProtectedBarrier=[bool]$barrier;Snapshot=$snapshot} + $entries.Add($entry);$queue.Enqueue($entry) + } + } + } catch {$diagnostics.Add($_.Exception.Message)} + [pscustomobject]@{Status=$(if($diagnostics.Count){'Incomplete'}else{'Complete'});Maximum=64;MaximumDepth=8;StartedUtc=$started.ToString('o');CompletedUtc=[DateTime]::UtcNow.ToString('o');Root=$root;Entries=@($entries.ToArray());Diagnostics=@($diagnostics.ToArray())} +} +function Get-WelaWmiStableDescendants { + param([string]$Namespace,$Observation) + $context=Get-WelaWmiDescendantContext + try { + $first=Get-WelaWmiDescendants $Namespace + if($Observation){$Observation.LastTree=$first} + if($first.Status -cne 'Complete'){throw ('Incomplete WMI descendant inventory: '+($first.Diagnostics -join '; '))} + $second=Get-WelaWmiDescendants $Namespace + if($Observation){$Observation.LastTree=$second} + if((Get-WelaWmiDescendantKey $first) -cne (Get-WelaWmiDescendantKey $second)){throw 'WMI descendant topology or full descriptor changed between observations.'} + $second|Add-Member NoteProperty Context $context + $second + } finally { + if((Get-WelaWmiDescendantContext) -cne $context){throw 'Full caller token, host, source or Winmgmt state changed while observing WMI descendants.'} + } +} +function Test-WelaWmiDescendantOutcomes { + param($Before,$After,[array]$Definitions) + $outcomes=New-Object 'System.Collections.Generic.List[object]' + $diagnostics=New-Object 'System.Collections.Generic.List[string]' + if($After.Status -cne 'Complete'){$diagnostics.Add('Post-write descendant inventory is incomplete: '+($After.Diagnostics -join '; '))} + $map=@{};foreach($entry in $After.Entries){$map[$entry.Namespace]=$entry} + foreach($entry in $Before.Entries){ + $status='Unverified';$actual=$null;$message='' + try { + if(-not $map.ContainsKey($entry.Namespace)){throw 'Reviewed namespace disappeared or could not be observed.'} + $actual=$map[$entry.Namespace];$map.Remove($entry.Namespace) + if($actual.Parent -cne $entry.Parent -or $actual.Depth -ne $entry.Depth -or $actual.ProtectedBarrier -ne $entry.ProtectedBarrier){throw 'Descendant topology or observed protection changed.'} + $a=$entry.Snapshot.DescriptorJson|ConvertFrom-Json;$b=$actual.Snapshot.DescriptorJson|ConvertFrom-Json + if($entry.ProtectedBarrier){ + if($entry.Snapshot.DescriptorJson -cne $actual.Snapshot.DescriptorJson){throw 'Protected namespace or its subtree changed.'} + $status='ProtectedUnchanged' + } else { + # Allow only SACL_PRESENT to appear. Every other control and full + # owner/group/DACL/unknown descriptor property remains identical. + if((ConvertTo-WelaWmiJson @($a.PSObject.Properties.Name|Sort-Object)) -cne (ConvertTo-WelaWmiJson @($b.PSObject.Properties.Name|Sort-Object))){throw 'Child descriptor property inventory changed.'} + foreach($property in $a.PSObject.Properties){ + if($property.Name -eq 'SACL'){continue} + if($property.Name -eq 'ControlFlags'){ + if(([uint32]$a.ControlFlags -band (-bnot 16)) -ne ([uint32]$b.ControlFlags -band (-bnot 16)) -or (([uint32]$a.ControlFlags -band 16) -ne 0 -and ([uint32]$b.ControlFlags -band 16) -eq 0)){throw 'Child descriptor controls changed.'} + } elseif((ConvertTo-WelaWmiJson $property.Value) -cne (ConvertTo-WelaWmiJson $b.($property.Name))){throw 'Child access or unknown descriptor field changed.'} + } + $remaining=New-Object 'System.Collections.Generic.List[object]' + foreach($ace in @($b.SACL)){if($null -ne $ace){$remaining.Add($ace)}} + foreach($ace in @($a.SACL)){ + if($null -eq $ace){continue};$found=-1 + for($i=0;$i -lt $remaining.Count;$i++){if((ConvertTo-WelaWmiJson $remaining[$i]) -ceq (ConvertTo-WelaWmiJson $ace)){$found=$i;break}} + if($found -lt 0){throw 'Original child audit/unknown ACE changed or disappeared.'};$remaining.RemoveAt($found) + } + $expected=@($Definitions|Where-Object {($_.AceFlags -band 2) -ne 0}|ForEach-Object {[pscustomobject]@{Sid=$_.Sid;AccessMask=$_.AccessMask;AceFlags=([uint32]$_.AceFlags -bor 16)}}) + foreach($ace in $remaining){if(-not @($expected|Where-Object {Test-WelaWmiAceMatch $ace $_}).Count){throw 'Unexplained child audit entry appeared.'}} + foreach($definition in $expected){if(@($remaining|Where-Object {Test-WelaWmiAceMatch $_ $definition}).Count -gt 1){throw 'Unexplained duplicate inherited child entry appeared.'}} + foreach($definition in $expected){if(-not @($b.SACL|Where-Object {Test-WelaWmiAceMatch $_ $definition}).Count){throw 'Requested inherited ACE was not observed; existing-child propagation is unverified.'}} + $status='InheritedAceObserved' + } + } catch {$message=$_.Exception.Message;$diagnostics.Add($entry.Namespace+': '+$message)} + $outcomes.Add([pscustomobject]@{Namespace=$entry.Namespace;Status=$status;Diagnostic=$message;Before=$entry.Snapshot;After=$(if($actual){$actual.Snapshot}else{$null})}) + } + foreach($entry in $map.Values){$diagnostics.Add('New unreviewed descendant: '+$entry.Namespace);$outcomes.Add([pscustomobject]@{Namespace=$entry.Namespace;Status='NewUnreviewedNamespace';Diagnostic='No pre-write snapshot or ownership established.';Before=$null;After=$entry.Snapshot})} + [pscustomobject]@{Status=$(if($diagnostics.Count){'Unverified'}else{'Observed'});Outcomes=@($outcomes.ToArray());Diagnostics=@($diagnostics.ToArray());Scope='Observed existing namespaces only. No atomic tree, namespace recreation identity, future-child, event, recovery ownership or Sigma claim.'} +} diff --git a/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 b/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 index 2ecabed1..86620775 100644 --- a/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 +++ b/tests/WmiNamespaceAuditing.DisposableNamespace.Tests.ps1 @@ -45,6 +45,7 @@ try { $definitions = @(Get-WelaWmiAuditDefinitions -Namespace 'root\default' -IncludeChildren) $definitions[0].Namespace = $namespace; $definitions[0].AceFlags = [uint32]$flags $entry = [pscustomobject]@{ Namespace=$namespace; Definitions=$definitions } + if($flags -eq 66){$entry|Add-Member NoteProperty Descendants (Get-WelaWmiStableDescendants $namespace)} $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' @@ -58,6 +59,7 @@ try { 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)) + if($flags -eq 66){$entry.Descendants=Get-WelaWmiStableDescendants $namespace} 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' diff --git a/tests/WmiNamespaceAuditing.Tests.ps1 b/tests/WmiNamespaceAuditing.Tests.ps1 index 0ccc0164..2ee0aebe 100644 --- a/tests/WmiNamespaceAuditing.Tests.ps1 +++ b/tests/WmiNamespaceAuditing.Tests.ps1 @@ -41,6 +41,8 @@ function Set-WelaWmiNamespaceDescriptor { 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 Get-WelaWmiChildNames {param($Namespace,$Maximum) @()} +function Get-WelaWmiDescendantContext {'Mock unchanged caller/host/source context'} 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 diff --git a/tests/WmiNamespaceDescendants.Cli.Tests.ps1 b/tests/WmiNamespaceDescendants.Cli.Tests.ps1 new file mode 100644 index 00000000..932e7aa9 --- /dev/null +++ b/tests/WmiNamespaceDescendants.Cli.Tests.ps1 @@ -0,0 +1,16 @@ +$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent;$engine=(Get-Process -Id $PID).Path +$cases=@( + @{Args=@('wmi-auditing','-Help');Exit=0;Pattern='WmiIncludeChildren'}, + @{Args=@('wmi-auditing','-WmiAction','Plan','-WmiNamespace','root\default','-WmiIncludeChildren','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('wmi-auditing','-WmiAction','Configure','-WmiIncludeChildren','-DryRun','-Help');Exit=0;Pattern='Usage:'}, + @{Args=@('wmi-auditing','-WmiAction','Audit','-WmiIncludeChildren','-DryRun','-Help');Exit=1;Pattern='DryRun'}, + @{Args=@('configure','-WmiIncludeChildren','-Help');Exit=1;Pattern='require wmi-auditing'}, + @{Args=@('wmi-auditing','unexpected','-WmiIncludeChildren','-Help');Exit=1;Pattern='positional|argument|Unrecognized'}, + @{Args=@('wmi-auditing','-WmiNamespace','root\default','-FileProbeAction','Run','-Help');Exit=1;Pattern='require file-access-probe|dedicated'} +) +foreach($c in $cases){ + $ErrorActionPreference='Continue';try{$text=@(& $engine -NoProfile -File (Join-Path $repo 'WELA.ps1') @($c.Args) 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'} + if($code -ne $c.Exit -or ($text -join "`n") -notmatch $c.Pattern){throw "CLI mismatch: $($c.Args -join ' ') : $code / $text"} +} +Write-Host "PASS: $($cases.Count) WMI inheritance public CLI assertions." +$global:LASTEXITCODE=0 diff --git a/tests/WmiNamespaceDescendants.Tests.ps1 b/tests/WmiNamespaceDescendants.Tests.ps1 new file mode 100644 index 00000000..db65c77d --- /dev/null +++ b/tests/WmiNamespaceDescendants.Tests.ps1 @@ -0,0 +1,123 @@ +$ErrorActionPreference='Stop' +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1') +$script:assertions=0 +function Assert($v,[string]$m){if(-not $v){throw $m};$script:assertions++} +function Throws([scriptblock]$f,[string]$m){$yes=$false;try{& $f|Out-Null}catch{$yes=$true};Assert $yes $m} +function Descriptor([uint32]$flags=32772){[pscustomobject]@{ControlFlags=$flags;Owner='owner';Group='group';DACL=@('a','b');SACL=@();Opaque='preserve'}} +function Snapshot([string]$ns,$d){[pscustomobject]@{Namespace=$ns;DescriptorJson=(ConvertTo-WelaWmiJson $d);DescriptorMof='native full descriptor';SaclReadPrivilege='test'}} +function Ace([uint32]$flags=82){[pscustomobject]@{AceType=2;AceFlags=$flags;AccessMask=262175;Trustee=[pscustomobject]@{SIDString='S-1-1-0'}}} +$script:tree=@{};$script:reads=0;$script:changeAt=0;$script:context='caller/host/source';$script:writes=0 +function Reset { + $script:tree=@{'root\default'=(Descriptor);'root\default\A'=(Descriptor);'root\default\A\B'=(Descriptor);'root\default\Protected'=(Descriptor 40964);'root\default\Protected\B'=(Descriptor)} + $script:reads=0;$script:changeAt=0;$script:context='caller/host/source';$script:writes=0;$script:prompt=$null;$script:failChildrenAfterWrite=$false +} +function Get-WelaWmiChildNames { + param($Namespace,$Maximum) + if($script:failChildrenAfterWrite -and $script:writes -gt 0){throw "Injected post-write child-read refusal."} + @($script:tree.Keys|Where-Object {$_ -clike ($Namespace+'\*') -and $_.Substring($Namespace.Length+1) -notmatch '\\'}|ForEach-Object {$_.Substring($Namespace.Length+1)}|Sort-Object) +} +function Get-WelaWmiDescendantContext {$script:context} +function Get-WelaWmiNamespaceSnapshot { + param($Namespace) + $script:reads++ + if($script:changeAt -and $script:reads -eq $script:changeAt){$script:tree['root\default\A'].Owner='racing owner'} + if(-not $script:tree.ContainsKey($Namespace)){throw 'Unknown namespace.'} + Snapshot $Namespace $script:tree[$Namespace] +} +function Set-WelaWmiNamespaceDescriptor { + param($Namespace,$ExpectedJson,$Definitions) + if((ConvertTo-WelaWmiJson $script:tree[$Namespace]) -cne $ExpectedJson){throw 'Immediate parent drift'} + $script:writes++ + foreach($d in $Definitions){$script:tree[$Namespace].SACL+=Ace $d.AceFlags} + $script:tree[$Namespace].ControlFlags=$script:tree[$Namespace].ControlFlags -bor 16 + # Model the provider's potential inherited-only propagation exactly, leaving protected tree unchanged. + foreach($ns in @('root\default\A','root\default\A\B')){$script:tree[$ns].SACL+=Ace;$script:tree[$ns].ControlFlags=$script:tree[$ns].ControlFlags -bor 16} +} +function Read-Host {param($Prompt)if($script:prompt){& $script:prompt};'Y'} +$defs=@(Get-WelaWmiAuditDefinitions -Namespace 'root\default' -IncludeChildren) +$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-desc-test-'+[guid]::NewGuid().ToString('N')) +try{ + Reset + $before=Get-WelaWmiStableDescendants 'root\default' + Assert ($before.Entries.Count -eq 4) 'Complete multilevel inventory.' + Assert (@($before.Entries|Where-Object ProtectedBarrier).Count -eq 2) 'Protected ancestor marks whole subtree.' + Assert ((Get-WelaWmiDescendantKey $before) -ceq (Get-WelaWmiDescendantKey (Get-WelaWmiStableDescendants 'root\default'))) 'Stable tree key omits observation clock.' + $script:changeAt=$script:reads+7 + Throws {Get-WelaWmiStableDescendants 'root\default'} 'Second-pass descriptor drift must fail.' + Reset;$script:tree['root\default\A\bad-child']=Descriptor + Assert ((Get-WelaWmiDescendants 'root\default').Status -eq 'Incomplete') 'Ambiguous child name rejected.' + Reset;foreach($i in 1..65){$script:tree['root\default\N'+$i]=Descriptor} + Assert ((Get-WelaWmiDescendants 'root\default').Status -eq 'Incomplete') 'Count overflow fails rather than truncates.' + Reset;$n='root\default';foreach($i in 1..9){$n+='\Deep';$script:tree[$n]=Descriptor} + Assert ((Get-WelaWmiDescendants 'root\default').Status -eq 'Incomplete') 'Depth overflow fails rather than truncates.' + Reset;$script:tree['root\default\A'].Opaque='x'*2097153 + Assert ((Get-WelaWmiDescendants 'root\default').Status -eq 'Incomplete') 'Serialized descriptor budget enforced.' + Reset;$script:tree['root\default'].Opaque='x'*2097153 + Assert ((Get-WelaWmiDescendants 'root\default').Status -eq 'Incomplete') 'The selected root also counts toward the descriptor budget.' + Reset + $p=@(Get-WelaWmiAuditPlan -Namespace 'root\default' -IncludeChildren) + $c=New-WelaConfigurationContext -Auto -DryRun -BackupPath $temp + Set-WelaWmiAuditControls $c $p + Assert ($script:writes -eq 0 -and $c.Results[0].Status -eq 'Skipped' -and -not (Test-Path $temp)) 'DryRun no state or journal mutation.' + $script:context='changed token';$c=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $temp token) + Set-WelaWmiAuditControls $c $p + Assert ($script:writes -eq 0 -and $c.Results[0].Status -eq 'Failed') 'Full context drift invalidates planned subtree.' + $script:context='caller/host/source' + $script:tree['root\default\New']=Descriptor + $c=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $temp stale) + Set-WelaWmiAuditControls $c $p + Assert ($script:writes -eq 0 -and $c.Results[0].Status -eq 'Failed') 'Stale membership blocks before journal or setter.' + Reset;$p=@(Get-WelaWmiAuditPlan -Namespace 'root\default' -IncludeChildren) + $script:prompt={$script:tree['root\default\A'].DACL=@('changed')} + $c=New-WelaConfigurationContext -BackupPath (Join-Path $temp prompt) + Set-WelaWmiAuditControls $c $p + Assert ($script:writes -eq 0 -and $c.Results[0].Status -eq 'Failed') 'Descendant drift during confirmation blocks parent setter.' + Reset;$p=@(Get-WelaWmiAuditPlan -Namespace 'root\default' -IncludeChildren) + $c=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $temp successful) + Set-WelaWmiAuditControls $c $p + $result=Complete-WelaConfiguration $c + Assert ($script:writes -eq 1 -and $result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Exact inherited propagation and protected preservation pass.' + $ob=$result.Results[0].DescendantVerification.Observation + Assert (@($ob.Outcomes|Where-Object Status -eq InheritedAceObserved).Count -eq 2) 'Two inherited readbacks represented.' + Assert (@($ob.Outcomes|Where-Object Status -eq ProtectedUnchanged).Count -eq 2) 'Two protected readbacks represented.' + $journal=Get-Content (Join-Path $temp successful/before.jsonl)|ConvertFrom-Json + Assert ($journal.Before.Descendants.Entries.Count -eq 4 -and $journal.Before.DescriptorMof -eq 'native full descriptor') 'Original journal fields and full subtree retained.' + Assert ((Get-WelaWmiDescendantKey $journal.Before.Descendants) -ceq (Get-WelaWmiDescendantKey $p[0].Descendants)) 'Journal serialization does not truncate original child snapshots.' + $script:tree['root\default\A'].Opaque='drift' + Assert ((Complete-WelaConfiguration $c).ExitCode -eq 1) 'Final child drift propagates failure.' + Reset;$script:tree['root\default'].SACL+=Ace 66;$script:tree['root\default'].ControlFlags=32788 + $unverified=@(Get-WelaWmiAuditPlan -Namespace 'root\default' -IncludeChildren) + Assert ($unverified[0].Status -eq 'Unknown' -and $unverified[0].Diagnostic -match 'descendants are unverified') 'Already-compliant parent cannot imply descendant compliance.' + $c=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $temp unverified) + Set-WelaWmiAuditControls $c $unverified + Assert ($script:writes -eq 0 -and (Complete-WelaConfiguration $c).ExitCode -eq 1) 'Missing existing-child inheritance fails without an unnecessary parent rewrite.' + Reset;$p=@(Get-WelaWmiAuditPlan -Namespace 'root\default' -IncludeChildren);$script:failChildrenAfterWrite=$true + $c=New-WelaConfigurationContext -Auto -BackupPath (Join-Path $temp partial) + Set-WelaWmiAuditControls $c $p + $r=Complete-WelaConfiguration $c + Assert ($r.ExitCode -eq 1 -and $script:writes -eq 1 -and $r.Results[0].Status -eq 'Failed') 'Post-write enumeration failure propagates a nonzero result.' + $v=$r.Results[0].DescendantVerification + Assert ($v.ParentSetterAttempted -and $v.ParentSetterAccepted -and $null -eq $v.Observation -and $v.LastTree.Status -eq 'Incomplete') 'Partial read failure retains parent-write flags and incomplete native observations without claiming verified outcomes.' + # Each unrelated mutation invalidates observed propagation, even when required ACE still exists. + foreach($kind in @('Owner','Dacl','Control','Unknown','NewProperty','Protected','Removed','Extra','Duplicate','Missing','New')){ + Reset;$a=Get-WelaWmiStableDescendants 'root\default';$null=Set-WelaWmiNamespaceDescriptor 'root\default' $a.Root.DescriptorJson $defs + switch($kind){ + Owner {$script:tree['root\default\A'].Owner='other'} + Dacl {$script:tree['root\default\A'].DACL=@('other')} + Control {$script:tree['root\default\A'].ControlFlags=$script:tree['root\default\A'].ControlFlags -bor 256} + Unknown {$script:tree['root\default\A'].Opaque='other'} + NewProperty {$script:tree['root\default\A']|Add-Member NoteProperty NewOpaque 1} + Protected {$script:tree['root\default\Protected\B'].SACL+=Ace} + Removed {$script:tree.Remove('root\default\A\B')} + Extra {$script:tree['root\default\A'].SACL+=Ace 64} + Duplicate {$script:tree['root\default\A'].SACL+=Ace} + Missing {$script:tree['root\default\A'].SACL=@()} + New {$script:tree['root\default\Unreviewed']=Descriptor} + } + $b=Get-WelaWmiStableDescendants 'root\default' + Assert ((Test-WelaWmiDescendantOutcomes $a $b $defs).Status -eq 'Unverified') "$kind child change must fail verification." + } + Write-Host "PASS: $script:assertions bounded WMI descendant assertions." +}finally{if(Test-Path $temp){Remove-Item $temp -Recurse -Force}} diff --git a/tests/WmiNamespaceDescendants.Windows.Tests.ps1 b/tests/WmiNamespaceDescendants.Windows.Tests.ps1 new file mode 100644 index 00000000..2bd38d5f --- /dev/null +++ b/tests/WmiNamespaceDescendants.Windows.Tests.ps1 @@ -0,0 +1,124 @@ +param([switch]$AllowDisposableNamespaceWrite,[string]$EvidencePath='wmi-descendants-native.json') +$ErrorActionPreference='Stop' +if(-not $AllowDisposableNamespaceWrite -or $env:OS -ne 'Windows_NT'){throw 'Requires disposable Windows and explicit namespace-write consent.'} +$repo=Split-Path $PSScriptRoot -Parent +. (Join-Path $repo 'scripts/Configuration.ps1') +. (Join-Path $repo 'scripts/WmiNamespaceAuditing.ps1') +. (Join-Path $repo 'scripts/WmiProbe.ps1') +Initialize-WelaWmiInterop;Initialize-WelaWmiProbeNative +$script:assertions=0 +function Assert($v,[string]$m){if(-not $v){throw $m};$script:assertions++} +function Safety { + $masks=[ordered]@{};foreach($p in (Get-Content (Join-Path $repo 'config/audit_profiles.json') -Raw|ConvertFrom-Json).catalog){$masks[$p.guid]=Get-WelaAuditPolicyMask $p.guid} + if($masks.Count -ne 59){throw 'Complete 59-subcategory inventory required.'} + [pscustomobject]@{Token=(Get-WelaWmiProbeTokenKey ([Wela.WmiProbe.Native]::Snapshot()));Masks=$masks;Precedence=(Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy);Services=@(Get-Service Winmgmt,EventLog|Sort-Object Name|Select-Object Name,@{n='Status';e={[string]$_.Status}})} +} +$owned=New-Object 'System.Collections.Generic.List[object]' +function New-OwnedNamespace([string]$Parent,[string]$Name){ + $factory=New-Object System.Management.ManagementClass -ArgumentList ('\\.\'+$Parent+':__Namespace');$instance=$null + try{ + $instance=$factory.CreateInstance();$instance.Name=$Name;$o=New-Object System.Management.PutOptions;$o.Type=[System.Management.PutType]::CreateOnly + $path=$instance.Put($o);$owned.Add([pscustomobject]@{Parent=$Parent;Name=$Name;Path=$Parent+'\'+$Name;Instance=$instance;Removed=$false});$instance=$null + Assert ($path.RelativePath -ieq ('__NAMESPACE.Name="'+$Name+'"')) 'Created identity differs from owned request.' + $Parent+'\'+$Name + }finally{if($instance){$instance.Dispose()};$factory.Dispose()} +} +function Entry([string]$Namespace){ + $d=@(Get-WelaWmiAuditDefinitions -Namespace 'root\default' -IncludeChildren);$d[0].Namespace=$Namespace + [pscustomobject]@{Namespace=$Namespace;Definitions=$d;Descendants=(Get-WelaWmiStableDescendants $Namespace)} +} +function Configure($Entry,[string]$Name,[switch]$DryRun){ + $c=New-WelaConfigurationContext -Auto -DryRun:$DryRun -BackupPath (Join-Path $backup $Name) + Set-WelaWmiAuditControls -Context $c -Plan @($Entry) + Complete-WelaConfiguration -Context $c -Scope wmi-namespace-sacl-only +} +function Observe-OwnedProtection([string]$Namespace){ + $before=Get-WelaWmiNamespaceSnapshot $Namespace + $privilege=New-Object Wela.WmiSecurityPrivilege;$connection=$null;$response=$null + try{ + $connection=New-WelaWmiConnection $Namespace;$descriptor=Get-WelaWmiNativeDescriptor $connection + $request=$descriptor.Clone();$request.DACL=$null;$request.Owner=$null;$request.Group=$null + $request.ControlFlags=([uint32]$descriptor.ControlFlags -band [uint32]4294967291) -bor [uint32]8208 + $parameters=$connection.GetMethodParameters('SetSecurityDescriptor');$parameters.Descriptor=$request + $response=$connection.InvokeMethod('SetSecurityDescriptor',$parameters,$null) + }finally{try{if($connection){$connection.Dispose()}}finally{$privilege.Dispose()}} + $after=Get-WelaWmiNamespaceSnapshot $Namespace;$a=$before.DescriptorJson|ConvertFrom-Json;$b=$after.DescriptorJson|ConvertFrom-Json + foreach($property in $a.PSObject.Properties){if($property.Name -ne 'ControlFlags'){Assert ((ConvertTo-WelaWmiJson $property.Value) -ceq (ConvertTo-WelaWmiJson $b.($property.Name))) 'Protection fixture changed an unrelated descriptor field.'}} + Assert (([uint32]$a.ControlFlags -band (-bnot 8192)) -eq ([uint32]$b.ControlFlags -band (-bnot 8192))) 'Protection fixture changed unrelated controls.' + [pscustomobject]@{Namespace=$Namespace;ReturnValue=$response.ReturnValue;Before=$before;After=$after;ProtectionObserved=(([uint32]$b.ControlFlags -band 8192) -ne 0)} +} +$backup=Join-Path ([IO.Path]::GetTempPath()) ('wela-wmi-tree-'+[guid]::NewGuid().ToString('N')) +$e=[ordered]@{SchemaVersion=1;Host=$env:COMPUTERNAME;Version=[Environment]::OSVersion.VersionString;PowerShell=$PSVersionTable.PSVersion.ToString();Head=$env:GITHUB_SHA;Cases=@();Before=$null;After=$null;Sources=@();Cleanup=@();Complete=$false;Failure=$null} +$failure=$null +try{ + $e.Before=Safety + $root=New-OwnedNamespace root ('WelaInheritance_'+[guid]::NewGuid().ToString('N')) + $child=New-OwnedNamespace $root Existing + $grand=New-OwnedNamespace $child Grandchild + $special=New-OwnedNamespace $root Explicit + # Owned child with a distinct explicit failure ACE; access descriptors remain intact. + $s=Get-WelaWmiNamespaceSnapshot $special + $specialDef=[pscustomobject]@{Sid='S-1-5-18';AccessMask=[uint32]1;AceFlags=[uint32]128} + $null=Set-WelaWmiNamespaceDescriptor $special $s.DescriptorJson @($specialDef) + $protected=New-OwnedNamespace $root Protected + $s=Get-WelaWmiNamespaceSnapshot $protected + $null=Set-WelaWmiNamespaceDescriptor $protected $s.DescriptorJson @($specialDef) + $protection=Observe-OwnedProtection $protected + Assert ($protection.ReturnValue -eq 0 -and $protection.ProtectionObserved) 'Reviewed native fixture did not retain the requested SACL protection bit.' + $protectedGrand=New-OwnedNamespace $protected Grandchild + $e.Cases+=@{Name='NativeProtectionObservation';Observation=$protection} + $p=Entry $root + Assert ($p.Descendants.Entries.Count -eq 5) 'All existing children and grandchildren are captured.' + $dry=Configure $p dry -DryRun + Assert ($dry.ExitCode -eq 0 -and $dry.Results[0].Status -eq 'Skipped' -and -not (Test-Path $backup)) 'Tree dry-run wrote state or backup.' + Assert ((Get-WelaWmiDescendantKey (Get-WelaWmiStableDescendants $root)) -ceq (Get-WelaWmiDescendantKey $p.Descendants)) 'Dry-run changed tree.' + $extra=New-OwnedNamespace $root Stale + $stale=Configure $p stale + Assert ($stale.ExitCode -eq 1 -and $stale.Results[0].Diagnostic -match 'changed after planning') 'New child failed to invalidate plan.' + Assert ((Get-WelaWmiNamespaceSnapshot $root).DescriptorJson -ceq $p.Descendants.Root.DescriptorJson) 'Stale topology allowed a parent setter.' + $p=Entry $root + $result=Configure $p apply + $after=Get-WelaWmiStableDescendants $root + $parentBefore=$p.Descendants.Root.DescriptorJson|ConvertFrom-Json;$parentAfter=$after.Root.DescriptorJson|ConvertFrom-Json + Assert (Test-WelaWmiDescriptorPreserved $parentBefore $parentAfter) 'Parent access or existing ACE preservation failed.' + Assert (@(Get-WelaWmiMissingAces $parentAfter $p.Definitions).Count -eq 0) 'Parent setter failed to apply requested inheritance ACE.' + $outcome=Test-WelaWmiDescendantOutcomes $p.Descendants $after $p.Definitions + Assert ($outcome.Status -eq 'Observed' -and $result.ExitCode -eq 0 -and $result.Results[0].Status -eq 'Applied') 'Reviewed native fixture failed complete descendant verification.' + Assert (@($outcome.Outcomes|Where-Object Status -eq InheritedAceObserved).Count -eq 4) 'Four existing unprotected descendants must show exact inherited ACEs.' + Assert (@($outcome.Outcomes|Where-Object Status -eq ProtectedUnchanged).Count -eq 2) 'Protected namespace and protected subtree must remain unchanged.' + $journal=@(Get-Content (Join-Path $backup 'apply/before.jsonl')|ConvertFrom-Json) + Assert ($journal.Count -eq 1 -and $journal[0].Before.Descendants.Entries.Count -eq 6) 'Original complete subtree missing from journal.' + Assert ((Get-WelaWmiDescendantKey $journal[0].Before.Descendants) -ceq (Get-WelaWmiDescendantKey $p.Descendants)) 'Journal tree differs from pre-write snapshots.' + $e.Cases+=@{Name='ExistingTree';Plan=$p;Result=$result;After=$after;Outcomes=$outcome;Journal=$journal} + # A genuinely newly created namespace independently demonstrates provider inheritance. + $future=New-OwnedNamespace $root Future + $futureSnapshot=Get-WelaWmiNamespaceSnapshot $future + $futureDescriptor=$futureSnapshot.DescriptorJson|ConvertFrom-Json + $expected=[pscustomobject]@{Sid=$p.Definitions[0].Sid;AccessMask=$p.Definitions[0].AccessMask;AceFlags=[uint32]82} + Assert (@($futureDescriptor.SACL|Where-Object {Test-WelaWmiAceMatch $_ $expected}).Count -eq 1) 'New child did not expose the exact native inherited ACE.' + $e.Cases+=@{Name='NewChildInheritance';Snapshot=$futureSnapshot;Expected=$expected} + $repeat=Configure (Entry $root) repeat + Assert ($repeat.ExitCode -eq $result.ExitCode) 'Repeat no longer reflects observed existing descendant outcomes.' + Assert ((Get-WelaWmiNamespaceSnapshot $root).DescriptorJson -ceq $after.Root.DescriptorJson) 'Idempotent parent repeat changed descriptor.' + $e.Cases+=@{Name='Repeat';Result=$repeat} + # A reviewed empty descendant set can complete, and a later child observes inheritance. + $empty=New-OwnedNamespace root ('WelaInheritance_'+[guid]::NewGuid().ToString('N')) + $emptyResult=Configure (Entry $empty) empty + Assert ($emptyResult.ExitCode -eq 0 -and $emptyResult.Results[0].Status -eq 'Applied') 'Reviewed empty tree did not apply.' + $emptyRepeat=Configure (Entry $empty) emptyrepeat + Assert ($emptyRepeat.ExitCode -eq 0 -and $emptyRepeat.Results[0].Status -eq 'AlreadyCompliant') 'Empty-tree repeat is not idempotent.' + $e.Cases+=@{Name='EmptyTree';Result=$emptyResult;Repeat=$emptyRepeat} +}catch{$failure=$_;$e.Failure=$_.Exception.ToString()} +finally{ + $cleanupErrors=@() + for($i=$owned.Count-1;$i -ge 0;$i--){$item=$owned[$i];try{$item.Instance.Delete();$names=@(Get-WelaWmiChildNames $item.Parent 64);if($item.Name -in $names){throw 'Owned namespace still exists after deletion.'};$item.Removed=$true}catch{$cleanupErrors+=$_.Exception.ToString()}finally{$item.Instance.Dispose()}} + $e.Cleanup=@($owned|Select-Object Parent,Name,Path,Removed);$e.CleanupErrors=$cleanupErrors + try{$e.After=Safety;Assert (($e.Before|ConvertTo-Json -Depth 12 -Compress) -ceq ($e.After|ConvertTo-Json -Depth 12 -Compress)) 'Full token/audit/precedence/services changed.'}catch{$cleanupErrors+=$_.Exception.ToString();$e.CleanupErrors=$cleanupErrors} + foreach($f in @('WELA.ps1','scripts/WmiNamespaceAuditing.ps1','scripts/WmiNamespaceDescendants.ps1','scripts/WmiProbe.ps1','scripts/WmiProbeNative.cs','scripts/Configuration.ps1','config/audit_profiles.json','tests/WmiNamespaceDescendants.Tests.ps1','tests/WmiNamespaceDescendants.Cli.Tests.ps1','tests/WmiNamespaceDescendants.Windows.Tests.ps1')){$e.Sources+=@{Path=$f;Sha256=(Get-FileHash (Join-Path $repo $f) -Algorithm SHA256).Hash.ToLowerInvariant()}} + $e.Assertions=$script:assertions + $e.Complete=($null -eq $failure -and $cleanupErrors.Count -eq 0) + $e|ConvertTo-Json -Depth 25|Set-Content -LiteralPath $EvidencePath -Encoding UTF8 + if(Test-Path $backup){Copy-Item $backup -Destination ($EvidencePath+'.journals') -Recurse;Remove-Item $backup -Recurse -Force} +} +if($failure){throw $failure};if(-not $e.Complete){throw 'Native WMI descendant cleanup or safety verification failed.'} +Write-Host "PASS: $script:assertions native WMI descendant assertions, complete owned-tree cleanup." diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md index 16561399..65422ede 100644 --- a/website/docs/resources/changelog.ja.md +++ b/website/docs/resources/changelog.ja.md @@ -11,6 +11,8 @@ - 組み込みの Security 4688 コマンドライン記録ポリシーのみを扱う `process-commandline` の Audit/Plan/Configure を追加しました。変更前の型付き状態、競合検出、監査の前提条件の分離、Windows 上の設定とイベント検証に対応します。#364、#365、#387 に関連します。 +- WMI の明示的な継承設定に、既存の子名前空間の上限付き調査、完全な変更前記録、ツリー変更の拒否を追加しました。親の SACL のみを書き込み、継承・保護状態と最終状態を確認します。不完全な伝播は失敗として扱い、子への直接書き込み、自動復元、イベントや Sigma 対応を保証しません。無関係な引数や余分な位置引数も拒否します。(Related #372) (@Shirofune-Security) + - 追加が証明された親名前空間のみの明示的な監査ACEを1つ削除する `wmi-sacl-recovery` の Plan/Recover を追加しました。完了済み設定の整合する記録、現在の完全な記述子、レビュー済みハッシュ、監査縮小への明示的同意を必須とし、他の記述子情報・ACE・トークン権限を保持します。部分書き込みの証跡と所有する実機名前空間の後始末を検証し、過去の名前空間・操作者の同一性やイベント・Sigmaの準備完了は保証しません。 (関連 #372, #365) (@Shirofune-Security) - 既に有効なローカル保存先で、現在の実行ユーザーによる固定 Windows PowerShell 5.1 子プロセスの完了した自動トランスクリプトを検証する `transcript-probe` を追加しました。実トークン・ログオン、標準ヘッダー/フッター、nonce・PID・時刻、件数を制限したファイル識別と出典確認により不明な結果を保持し、ポリシーやACLの変更・明示的トランスクリプト開始・Sigma/EVTX加算は行いません。Server 2022/2025と両WELA実行エンジン向けに、使い捨て標準ユーザーの成功・拒否テストを追加しました。 (#436) (@Shirofune-Security) diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md index 93b7b6f4..b1ffaff9 100644 --- a/website/docs/resources/changelog.md +++ b/website/docs/resources/changelog.md @@ -11,6 +11,8 @@ - Added `process-commandline` Audit/Plan/Configure for only the built-in Security 4688 command-line policy, with typed originals, drift checks, separate audit prerequisites and native Windows configuration/event validation. Related to #364, #365 and #387. +- Added bounded existing-descendant snapshots, full recovery evidence and stale-tree guards for explicit WMI inheritance. Parent-only SACL writes now require native inherited/protected readbacks and final drift checks; incomplete propagation fails without child setters, rollback ownership, event or Sigma credit. Unrelated or extra positional WMI command arguments are now refused. (Related #372) (@Shirofune-Security) + - Add reviewed `wmi-sacl-recovery` Plan/Recover for one proven explicit parent-only namespace audit ACE. Require matching completed configuration evidence, current full descriptor, reviewed hash and explicit audit-reduction consent; preserve all other descriptor fields, remaining ACEs and token privileges, retain partial-write evidence, and test owned native namespace cleanup. Historical namespace/operator identity and event/Sigma readiness remain unclaimed. (Related #372, #365) (@Shirofune-Security) - Added opt-in `transcript-probe` to verify a fixed current-account Windows PowerShell 5.1 child produces its own completed automatic transcript in an already enabled local destination. Actual token/logon, native header/footer, nonce/PID/time, bounded file identity and source checks preserve unverified outcomes without changing policy or ACLs, invoking explicit transcription or granting Sigma/EVTX credit. Added disposable standard-writer success/denial tests for Server 2022/2025 under both WELA host engines. (#436) (@Shirofune-Security)