diff --git a/.github/workflows/gpo-creation.yml b/.github/workflows/gpo-creation.yml
new file mode 100644
index 00000000..47c82ee2
--- /dev/null
+++ b/.github/workflows/gpo-creation.yml
@@ -0,0 +1,48 @@
+name: Disabled unlinked GPO creation regressions
+on:
+ push:
+ branches: ['**']
+ paths:
+ - 'WELA.ps1'
+ - 'scripts/GpoCreation.ps1'
+ - 'scripts/GpoAuditPackages.ps1'
+ - 'scripts/AdObjectSacl.ps1'
+ - 'scripts/EvtxRecovery.ps1'
+ - 'scripts/Configuration.ps1'
+ - 'modules/AuditProfiles.psm1'
+ - 'config/audit_profiles.json'
+ - 'docs/gpo-*'
+ - 'tests/GpoCreation*'
+ - '.github/workflows/gpo-creation.yml'
+ pull_request:
+ workflow_dispatch:
+permissions:
+ contents: read
+jobs:
+ gpo-creation:
+ timeout-minutes: 25
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [windows-2022, windows-2025]
+ runs-on: ${{ matrix.os }}
+ steps:
+ - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
+ - name: Windows PowerShell 5.1 safe fixtures
+ shell: powershell
+ run: ./tests/GpoCreation.Tests.ps1
+ - name: Windows PowerShell 5.1 public dispatch
+ shell: powershell
+ run: ./tests/GpoCreation.Cli.Tests.ps1
+ - name: Windows PowerShell 5.1 genuine backup reads and workgroup refusal
+ shell: powershell
+ run: ./tests/GpoCreation.Windows.Tests.ps1 -AllowHostedGpmcInstall -OutputPath "$env:RUNNER_TEMP/gpo-native-51"
+ - name: PowerShell 7 safe fixtures
+ shell: pwsh
+ run: ./tests/GpoCreation.Tests.ps1
+ - name: PowerShell 7 public dispatch
+ shell: pwsh
+ run: ./tests/GpoCreation.Cli.Tests.ps1
+ - name: PowerShell 7 genuine backup reads and workgroup refusal
+ shell: pwsh
+ run: ./tests/GpoCreation.Windows.Tests.ps1 -OutputPath "$env:RUNNER_TEMP/gpo-native-7"
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index bd202ab8..b741d18c 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 -Destination release-binaries/docs/
+ Copy-Item -Path ./docs/gpo-audit-packages.md, ./docs/gpo-package-deployment.md, ./docs/gpo-creation.md -Destination release-binaries/docs/
- name: Set Artifact Name
if: contains(matrix.info.os, 'windows') == true
diff --git a/CHANGELOG-Japanese.md b/CHANGELOG-Japanese.md
index 406734b9..738ca60a 100644
--- a/CHANGELOG-Japanese.md
+++ b/CHANGELOG-Japanese.md
@@ -4,6 +4,7 @@
**改善:**
+- 正規バックアップと現在の WELA 監査コンポーネントを照合し、新規・無効・未リンクの GPO のみを作成する `gpo-create` の Review / Plan / Create を追加しました。実ファイルとネイティブレポートの厳密な検証、明示的なドメイン/書き込み可能 DC、変更しない保護付きバックアップコピー、永続 GUID 記録、内容・無効状態・権限・リンク・バージョンの直前/最終確認で既存ポリシーを保護します。Windows テストは Microsoft の固定バックアップの読み取りと対象外ポリシー/ワークグループの拒否を確認し、実 AD/SYSVOL への正常インポートとクライアント/イベントの受け入れ検証は別途必要です。適用や Sigma の有効性は主張しません。(#427) (@Shirofune-Security)
- 既に無効なネイティブ購読のクエリと説明だけを変更する `wec-update` を追加。定義・実ホスト・コードの指紋、レビュー済み計画のハッシュ、永続レシート、直前確認と変更後の読み戻しにより、再作成や有効化をせずに変更を検証します。使い捨て Windows テストは実更新・復元と古い計画の拒否を確認します。稼働中ソースのブックマーク・配送・Sigma 準備状態は未検証です。 (#426) (@Shirofune-Security)
- 任意実行の`dns-analytical`を追加し、DNS Serverの分析ログを監査・計画・明示選択で設定できるようにしました。トレース再設定への個別同意、永続的な変更前記録、容量を制限したネイティブETLの退避とハッシュ検証に対応し、ACL・パス・既存の大きいバッファを保持します。退避失敗で停止した状態を失敗として報告します。使い捨てDNS環境のループバックイベント257と設定復元のテストを追加し、転送・Sigmaの利用可能性は未検証のままです。 (#425) (@Shirofune-Security)
- 読み取り専用の`wec-runtime`を追加し、WEC標準APIの稼働状態、数値エラー、UTC時刻と件数を制限した送信元別の観測結果を取得します。実行者・ホスト・定義の変更、不明な値と上限到達を明示し、既存のWEF・保存状態レポートでは元の文字列も保持します。過去の送信元一覧を現在の接続数とは扱わず、Activeからイベント到着やSigma利用可能性を推定しません。無効な使い捨てサブスクリプションで検証し、サービス状態とテスト対象を復元します。WEF・EVTXの合成テスト資料で時刻の末尾ゼロが失われる問題も修正しました。 (#424) (@Shirofune-Security)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c8b12b7d..a022c68a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,7 @@
**Improvements:**
+- Added opt-in `gpo-create` review, plan and new disabled/unlinked GPO creation from an exact genuine backup matched to current WELA audit components. Strict payload/native-report validation, explicit domain/writable-DC identity, protected unchanged backup copies, durable GUID receipts and fresh/final content, flags, permissions, link and version checks preserve existing policies. Native Windows tests read a pinned Microsoft backup and exercise broad-payload/workgroup refusal; positive AD/SYSVOL import and client/event acceptance remain pending, with no deployment or Sigma credit. (#427) (@Shirofune-Security)
- Added `wec-update` to review and apply query/description changes to one already disabled native subscription through existing-only WEC handles. Complete definition/context/code fingerprints, a separately reviewed plan hash, durable receipts, fresh checks and preserved-property readback reject drift without recreation or activation. Disposable Windows tests cover actual updates/restoration and stale plans; active-source bookmarks, delivery and Sigma readiness remain unverified. (#426) (@Shirofune-Security)
- Added opt-in `dns-analytical` auditing, planning and selective DNS Server channel configuration with explicit trace-reset consent, durable state records and bounded native ETL archives verified before resets. Preserve ACLs, paths and larger buffers; report stopped partial failures honestly. Added disposable standalone-DNS tests for loopback event 257 and exact configuration restoration; forwarding and Sigma readiness remain unverified. (#425) (@Shirofune-Security)
- Added read-only `wec-runtime` with typed native WEC activity, numeric errors, UTC timestamps and bounded per-source observations. Actual reader/context and definition checks keep partial reads, caps and drift explicit; existing WEF/retention inventories retain raw text alongside typed fields. Historical source lists are not connection counts and Active grants no arrival or Sigma credit. Disposable disabled-subscription tests restore service state and remove only their owned fixture. Also fixed synthetic WEF/EVTX fixture timestamp roundtrips without weakening bundle validation. (#424) (@Shirofune-Security)
diff --git a/WELA.ps1 b/WELA.ps1
index 4b939c0c..210ae1d8 100644
--- a/WELA.ps1
+++ b/WELA.ps1
@@ -65,6 +65,8 @@
[switch]$EnablePrivacyChannel,
[string]$ScoreProfile,
[string]$ScoreEvidencePath,
+ [ValidateSet('Review','Plan','Create')][string]$GpoCreateAction = 'Review',
+ [string]$GpoCreateConfigPath,
[ValidateSet('Plan','Export','Verify')][string]$GpoAction = 'Plan',
[string]$GpoProfile,
[string]$GpoOutputPath,
@@ -165,6 +167,7 @@ Import-Module (Join-Path $ScriptRoot "modules/WefSubscriptions.psm1") -ErrorActi
. (Join-Path $ScriptRoot "scripts/GpoAuditPackages.ps1")
. (Join-Path $ScriptRoot "scripts/IntuneAuditExport.ps1")
. (Join-Path $ScriptRoot "scripts/EvtxRecovery.ps1")
+. (Join-Path $ScriptRoot "scripts/GpoCreation.ps1")
. (Join-Path $ScriptRoot "scripts/AuditRecovery.ps1")
# 64bit の PowerShell と GPO が読むのは Wow6432Node の無いパス。32bit 用に両方を扱う。
@@ -1863,6 +1866,7 @@ Usage:
./WELA.ps1 dns-analytical -Help # Dedicated DNS Server direct-channel lifecycle
./WELA.ps1 wec-runtime -WecRuntimeId subscription-id -ResultsPath new-runtime.json
./WELA.ps1 targeted-sacl -Help # Selected existing local SACL targets; read-only by default
+ ./WELA.ps1 gpo-create -Help # Create only a new disabled, unlinked GPO from reviewed genuine backup
./WELA.ps1 gpo-package -GpoAction Plan -GpoProfile wela-2.2.0 -Role Client -Build 26100
./WELA.ps1 gpo-package -GpoAction Export -GpoProfile wela-2.2.0 -Role Client -Build 26100 -GpoOutputPath .\audit-components
./WELA.ps1 gpo-package -GpoAction Verify -GpoOutputPath .\audit-components
@@ -1971,6 +1975,9 @@ if ($Cmd -eq 'score' -and @($PSBoundParameters.Keys | Where-Object { $_ -notin @
throw 'score accepts only score, scenario, optional-selection and report options. No command was run.'
}
+if ($Cmd -ne 'gpo-create' -and @($PSBoundParameters.Keys | Where-Object {$_ -in @('GpoCreateAction','GpoCreateConfigPath')}).Count) {throw 'GPO creation options require gpo-create. No command was run.'}
+if ($Cmd -eq 'gpo-create' -and @($PSBoundParameters.Keys | Where-Object {$_ -notin @('Cmd','GpoCreateAction','GpoCreateConfigPath','Auto','DryRun','BackupPath','Help')}).Count) {throw 'gpo-create accepts only its dedicated config/action and consent/recovery options. No command was run.'}
+
if ($Cmd -ne 'gpo-package' -and @($PSBoundParameters.Keys | Where-Object { $_ -in @('GpoAction','GpoProfile','GpoOutputPath','GpoMinimumMode') }).Count) {
throw 'GPO package options require gpo-package. No command was run.'
}
@@ -2071,7 +2078,7 @@ if ($Cmd -ne 'ad-object-sacl' -and @($PSBoundParameters.Keys | Where-Object {
}).Count) {
throw 'AD object SACL options require the dedicated ad-object-sacl command. No command was run.'
}
-if ($DryRun -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
+if ($DryRun -and -not ($Cmd -eq 'gpo-create' -and $GpoCreateAction -eq 'Create') -and -not ($Cmd -eq 'dns-analytical' -and $DnsAction -eq 'Configure') -and -not ($Cmd -eq 'targeted-sacl' -and $TargetSaclAction -eq 'Configure') -and -not ($Cmd -eq 'audit-recovery' -and $RecoveryAction -eq 'Restore') -and -not ($Cmd -eq 'gpo-package' -and $GpoAction -eq 'Export') -and -not ($Cmd -eq 'audit-integrity' -and $IntegrityAction -eq 'Configure') -and -not ($Cmd -eq 'audit-notifications' -and $NotificationAction -eq 'Configure') -and -not ($Cmd -eq 'ldap-diagnostics' -and $LdapAction -eq 'Configure') -and -not ($Cmd -eq 'applocker-readiness' -and $AppLockerAction -eq 'Import') -and $Cmd -notin @('configure', 'configure-eventlogs') -and
-not ($Cmd -eq 'provider-packs' -and $ProviderAction -eq 'Configure') -and
-not ($Cmd -eq 'firewall-logging' -and $FirewallAction -eq 'Configure') -and
-not ($Cmd -eq 'smb-auditing' -and $SmbAction -eq 'Configure') -and
@@ -2144,6 +2151,12 @@ switch ($Cmd.ToLower()) {
$report.Configuration | Select-Object Label,Numerator,Denominator,Percent,Unknown | Format-List
$report.Readiness | Select-Object Label,Numerator,Denominator,Percent,Ready,ApplicableUniqueRules | Format-List
}
+ 'gpo-create' {
+ if ($Help) { Write-Host 'Usage: ./WELA.ps1 gpo-create [-GpoCreateAction Review|Plan|Create] -GpoCreateConfigPath .\gpo-create.json [-Auto] [-DryRun] [-BackupPath new-local-directory]. Review verifies a genuine native backup against a WELA package; Plan/Create require its reviewed fingerprint. Create makes only a NEW disabled, unlinked GPO on the pinned writable DC. No existing GPO overwrite, linking, enabling or automatic deletion. See docs/gpo-creation.md.'; return }
+ $report=Invoke-WelaGpoCreateCommand -Action $GpoCreateAction -ConfigPath $GpoCreateConfigPath -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
+ $report | ConvertTo-Json -Depth 22 | Write-Output
+ if ($report.ExitCode) { exit $report.ExitCode }; return
+ }
'gpo-package' {
if ($Help) { Write-Host 'Usage: ./WELA.ps1 gpo-package [-GpoAction Plan|Export|Verify] [-GpoProfile profile-id -Role Client|MemberServer|DomainController|ADCS -Build number] [-GpoMinimumMode Reject|PromoteToBoth] [-IncludeOptional] [-GpoOutputPath directory] [-DryRun]. Export requires a fresh directory. These are offline components, not an importable GPO backup. See docs/gpo-audit-packages.md.'; return }
if ($Profile -or $Baseline -or $HtmlPath -or $Auto -or $BackupPath -or $PlanPath -or $ResultsPath) { throw 'gpo-package uses GpoProfile and GpoOutputPath. Export contains its JSON manifest/review; other profile, result, backup and configuration options are unsupported.' }
diff --git a/docs/gpo-audit-packages.md b/docs/gpo-audit-packages.md
index a2718ded..62cec995 100644
--- a/docs/gpo-audit-packages.md
+++ b/docs/gpo-audit-packages.md
@@ -58,3 +58,5 @@ Only built-in advanced Security audit profiles and the precedence template are s
The offline suite covers shared source/role/build selection, every mode/mask translation, exact-zero/default refusal, optional/role omissions, source fingerprint drift, CSV/template contents, tampering even with updated hashes, fresh-directory collisions, dry-run, filesystem guards and public CLI option boundaries. The Windows workflow targets Server 2022/2025 under PowerShell 5.1 and 7, verifies cross-edition package compatibility, calls native `secedit /validate` on the generated template and checks unchanged effective audit masks/precedence. It does not apply audit CSV or create a domain GPO.
Accepted file syntax and package round-trips do not establish domain import, exact-disable behavior, GPO propagation or event generation. Genuine backup preparation, create-unlinked/import/readback, client/member/DC/AD CS lab application and benign event/collector evidence remain pending. Issue #2 therefore retains deployment acceptance work beyond this package feature.
+
+The separate opt-in [`gpo-create`](gpo-creation.md) command can create a new disabled, unlinked candidate from a reviewed genuine narrow backup and matching package. Package export does not invoke it; positive AD/SYSVOL deployment acceptance remains separate.
diff --git a/docs/gpo-creation.md b/docs/gpo-creation.md
new file mode 100644
index 00000000..70f60588
--- /dev/null
+++ b/docs/gpo-creation.md
@@ -0,0 +1,85 @@
+# Create a disabled, unlinked audit GPO
+
+`gpo-create` validates a genuine, narrowly scoped GPMC backup against a current WELA `gpo-package`, then optionally creates one **new GPO with both policy sides disabled and no links**. It never overwrites an existing GPO, activates policy, links an OU/domain/site, changes delegation/filtering, refreshes clients, or deletes a failed candidate. Scope is built-in Windows advanced system audit policy plus DWORD `SCENoApplyLegacyAuditPolicy=1`; Sysmon and other WELA controls are excluded.
+
+This command requires Windows PowerShell 5.1 or PowerShell 7 on Windows, installed GPMC/GroupPolicy management tools, an authenticated account authorized to create GPOs, and a host joined to the explicitly selected domain. Production code installs no tools. Only **single-domain forests** are supported: domain/OU and forest-site links can then be checked through one explicitly pinned writable DC. Workgroup hosts, RODCs, aliases, IP literals, mismatched domain GUIDs, unknown permissions and multi-domain forests are refused.
+
+## Prepare genuine inputs
+
+1. Use `gpo-package` to export and review the intended built-in source profile, role/build, omissions and exact masks. A package is not itself importable. One-sided minimum masks require explicit `PromoteToBoth`; unvalidated zero-mask deployment remains blocked.
+2. Follow [source preparation](gpo-package-deployment.md) on an isolated authorized domain. Create an unlinked source GPO and configure **only** the selected advanced audit GUIDs/masks and audit precedence. Leave all User settings empty. Do not add scripts, registry policy files, rights, security options, WMI filters or other extensions.
+3. Disable **both Computer and User settings on that source GPO before making its genuine Backup-GPO/GPMC backup**. Do not patch `Backup.xml`, strip unwanted files or fabricate a backup from WELA components. The source backup must already declare both sides disabled; import does not provide an assumption that enabled flags are preserved.
+4. Retain the complete genuine backup directory, backup-instance ID (distinct from the original GPO GUID), and WELA package on ordinary local fixed-drive paths. No UNC/device/stream/reparse input paths are accepted. Keep the inputs protected against concurrent editing.
+
+The validator checks cached and native GPMC reports, actual `audit.csv` GUID/mask rows, the typed security template, backup core registrations, metadata and filesystem copy directives, and every selected-backup file/directory. Only the two audit/precedence payloads plus native metadata and known empty directories are supported. The exact absent legacy-ADM wildcard placeholder retained by genuine GPMC backups is allowed only without a Location/callback or any ADM files; Registry.pol and registry policy registration remain refused. Localized display labels do not control policy comparisons. Unknown data is refused rather than silently filtered. Broader SCT/LGPO backups normally fail this narrow validation; a genuine source GPO must be prepared separately.
+
+## Review, plan and create
+
+Save an explicit config, for example `C:\Review\gpo-create.json`:
+
+```json
+{
+ "SchemaVersion": 1,
+ "PackagePath": "C:\\Review\\audit-components",
+ "BackupRoot": "C:\\Review\\genuine-gpo-backups",
+ "BackupId": "11111111-1111-1111-1111-111111111111",
+ "Domain": "lab.example.test",
+ "DomainGuid": "22222222-2222-2222-2222-222222222222",
+ "Dc": "dc01.lab.example.test",
+ "Name": "WELA Audit - reviewed candidate",
+ "ReviewedSha256": ""
+}
+```
+
+Use actual reviewed IDs. `DomainGuid` is the directory domain object's `objectGUID`, not its SID or a source backup ID. Paths may be absolute or relative to this config. Fields are strictly checked; unknown/duplicate JSON properties are rejected. `Name` must be a new plain 2–128 character name and cannot be a default policy name.
+
+```powershell
+# Native backup/package review only; no domain connection or output writes.
+.\WELA.ps1 gpo-create -GpoCreateConfigPath C:\Review\gpo-create.json
+```
+
+Review the returned profile and source/backup identity. Copy the resulting lowercase `ReviewedSha256` into that config. The fingerprint includes every selected backup file, directory, current WELA package file and existing native backup-root manifest. It is an integrity/review binding, not proof of a trusted publisher or valid AD deployment.
+
+```powershell
+# Adds actual domain/DC identity and unique-name checks; remains read-only.
+.\WELA.ps1 gpo-create -GpoCreateAction Plan -GpoCreateConfigPath C:\Review\gpo-create.json
+
+# No output or GPO creation; still performs real prerequisite reads.
+.\WELA.ps1 gpo-create -GpoCreateAction Create -GpoCreateConfigPath C:\Review\gpo-create.json `
+ -BackupPath C:\Review\candidate-receipts -DryRun
+
+# Prompts before creating the new candidate. -Auto supplies unattended consent.
+.\WELA.ps1 gpo-create -GpoCreateAction Create -GpoCreateConfigPath C:\Review\gpo-create.json `
+ -BackupPath C:\Review\candidate-receipts
+```
+
+`BackupPath` must be a fresh directory with an existing local parent. The directory receives an owner/SYSTEM/Administrators ACL. WELA writes a reviewed plan and creation-intent receipt, holds reviewed input files against write/delete, and copies the genuine backup **unchanged** into this protected directory. It revalidates those copied bytes; no fake native metadata is generated. The protected copy is the native import source.
+
+The shared configuration runner provides dry-run, consent, before-state journal and final verification. WELA calls `New-GPO` without a starter GPO and records the returned GUID immediately using a new, flushed receipt. It then checks that the exact new object is empty and unlinked, disables both sides, captures a full blank-target receipt, and freshly repeats identity/content/link/permission/version checks before native GPMC import. Both the COM operation and its `GPMResult.OverallStatus()` must succeed. Import targets only the returned GUID and uses no migration table. The readback compares native report and actual pinned-DC SYSVOL payloads, disabled flags, domain/site links, owner/group/DACL report, object identity, and coherent AD/SYSVOL versions. Final readback must still match.
+
+Native creation initially returns an **empty, unlinked** GPO; disabling happens immediately after its durable identity receipt and empty-object checks. If that receipt cannot be persisted, import and further changes are refused. The precreation marker/name receipt and returned GUID identify this empty residual object. WELA never claims a failed run left a verified disabled candidate.
+
+## Evidence and recovery
+
+A successful result is `DisabledUnlinkedCandidateVerified` on the pinned DC at that time. `DeploymentVerified` remains false and Sigma EVTX credit remains zero. The package's declared role/build is not a generated WMI/security filter and does not constrain future linking. Security filtering/delegation stays at the new GPO's native defaults; review it separately before any later enabling or linking.
+
+Retain `reviewed-plan.json`, `creation-intent.json`, `created-gpo.json`, `blank-target.json`, `import-intent.json`, `before.jsonl`, `result.json`, and the copied genuine backup. Failure stops the workflow and preserves available receipts; it does not retry into an existing GPO or automatically delete/restore anything. If native creation succeeded but returned no identity or saving the receipt failed, search the exact selected DC/name and unique comment marker from `creation-intent.json`. Inspect the object and all current links before any separately authorized cleanup. Existing evidence paths are never reused.
+
+These checks are **not a transaction or compare-and-swap lock across AD and SYSVOL**. Another authorized administrator can race between creation, name/flags/link checks, native import and readback. A fresh/final mismatch is a failed candidate, not automatic rollback authority. Coordinated change control and subsequent replication checks remain necessary. Readback establishes neither replication to other DCs nor client policy processing, precedence after refresh, persistence, event generation, forwarding or detection success.
+
+## Validation boundary
+
+Safe fixtures exercise the public orchestration with native domain adapters mocked: source/payload drift, unrelated policies, duplicate names, domain changes, blank-target changes, receipt failures, import failures, permissions/content/link mismatches, final drift, dry-run and decline. Public CLI tests check option dispatch and negative exit behavior.
+
+Windows Server 2022/2025 CI exercises real GPMC backup/report/OverallStatus using Microsoft's unchanged **Windows Server 2022 Security Baseline** archive from the [Security Compliance Toolkit](https://www.microsoft.com/en-us/download/details.aspx?id=55319), pinned to SHA-256 `49590cc694626d171fc934fafea6494f13ecd3843086704b7a5b98355909b8e0`. Its broad policy is deliberately refused. Actual workgroup-host refusal and unchanged native local audit/precedence state are checked in PowerShell 5.1 and 7. The test can explicitly install GPMC only on disposable GitHub-hosted runners; it creates no domain and performs no domain policy writes.
+
+**Positive creation/import from a genuine narrow backup into AD/SYSVOL remains pending isolated-domain acceptance.** Required follow-up: collect the real source backup and typed native results, confirm both sides disabled and no domain/OU/site links before/after import, review permissions and versions, test failure/recovery with concurrent administrators, then separately stage representative Windows 11, member server, DC and AD CS client-policy/event/arrival checks. Hosted workgroup evidence is not a substitute for these steps. Issue #2 remains related work until acceptance is complete.
+
+## Microsoft sources
+
+- [New-GPO: creates an unlinked GPO and rejects duplicate names](https://learn.microsoft.com/en-us/powershell/module/grouppolicy/new-gpo?view=windowsserver2025-ps)
+- [Native backup handling and archive preservation](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/group-policy/group-policy-backup-restore)
+- [GPMC GetDomain: explicit DC with flags 0](https://learn.microsoft.com/en-us/windows/win32/api/gpmgmt/nf-gpmgmt-igpm-getdomain)
+- [GPMBackup report generation](https://learn.microsoft.com/en-us/windows/win32/api/gpmgmt/nf-gpmgmt-igpmbackup-generatereport)
+- [Native GPO import: settings replacement and destination ACL/link preservation](https://learn.microsoft.com/en-us/windows/win32/api/gpmgmt/nf-gpmgmt-igpmgpo-import)
+- [OverallStatus must be checked as well as the native operation](https://learn.microsoft.com/en-us/windows/win32/api/gpmgmt/nf-gpmgmt-igpmresult-overallstatus)
diff --git a/docs/gpo-package-deployment.md b/docs/gpo-package-deployment.md
index 4b7db116..31528af0 100644
--- a/docs/gpo-package-deployment.md
+++ b/docs/gpo-package-deployment.md
@@ -7,7 +7,7 @@ This WELA folder contains **deployment components, not a GPO backup**. Do not pa
1. Verify the component package with the same reviewed WELA version. Read `review.md`, including every omitted/expanded row and object/service prerequisites. Confirm the intended target role, build, scope and source version; these are declared package inputs, not observations of a domain's computers.
2. On a disposable, snapshotted lab, use GPMC to create a **new unlinked source GPO**. Leave existing GPOs, default domain policies and links untouched. In the Group Policy Management Editor, enter only the exported rows under Computer Configuration > Policies > Windows Settings > Security Settings > Advanced Audit Policy Configuration. `ExportMask` 1 is Success, 2 is Failure, 3 is Success and Failure. Leave omitted rows Not Configured in this new GPO; do not interpret omission as disabling auditing.
3. In the same GPO, enable Security Options > **Audit: Force audit policy subcategory settings (Windows Vista or later) to override audit policy category settings**. The companion `GptTmpl.inf` shows the exact DWORD requirement. Do not add legacy category audit policy, audit failure options, privileges, SACLs or unrelated registry settings.
-4. Review GPMC's Settings report against the package. Require an exact match for the selected audit GUIDs/masks and precedence=1, with no unexpected Computer or User policy settings. Confirm no links or WMI filter. Review the GPO's security filtering/delegation independently. Back up this source GPO through GPMC or `Backup-GPO` into a fresh protected directory, retaining its **backup-instance ID**, source GPO ID, report and hashes.
+4. Disable both Computer and User settings on the source GPO before backing it up. Review GPMC's Settings report against the package. Require an exact match for the selected audit GUIDs/masks and precedence=1, with no unexpected Computer or User policy settings. Confirm no links or WMI filter. Review the GPO's security filtering/delegation independently. Back up this source GPO through GPMC or `Backup-GPO` into a fresh protected directory, retaining its **backup-instance ID**, source GPO ID, report and hashes.
This manual source-GPO preparation is the supported path for a narrow, genuine domain backup. Microsoft documents creating an [unlinked GPO](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/manage/group-policy/group-policy-management-console) and generating backups through [Backup-GPO](https://learn.microsoft.com/en-us/powershell/module/grouppolicy/backup-gpo?view=windowsserver2025-ps).
@@ -19,49 +19,11 @@ Obtain Microsoft's signed LGPO utility and its documentation from the [Security
LGPO `/b` backs up local policy, including security settings, current advanced audit state, registry policy and configured extensions. **Its output can include settings absent from this package**, even on a lab machine. Review the complete backup in GPMC. If it contains extras, edit a newly created isolated source GPO through GPMC and make a new genuine backup; do not remove files or patch XML inside the archive. A generic local-policy backup is not automatically a narrow WELA audit-only backup. Microsoft explains the difference between [AuditPol state and local policy](https://learn.microsoft.com/en-us/troubleshoot/windows-server/active-directory/auditpol-local-security-policy-results-differ).
-## Reviewed create-new-unlinked procedure
+## Reviewed create-new-disabled-unlinked command
-The following is an **operator procedure for an already prepared, genuine, fully reviewed backup**. It is not generated executable content and WELA does not run it. Review its backup by instance ID through GPMC's Manage Backups/Settings report (or the native `GPMBackupDir.GetBackup` and `GPMBackup.GenerateReport` APIs). A CSV folder is insufficient. Require only the intended computer audit settings and precedence, with no unknown extensions, scripts, preferences, per-user audit entries or unrelated settings.
+WELA now provides the separate opt-in [`gpo-create` command](gpo-creation.md) for an already prepared genuine narrow backup. Its Review action compares native GPMC reports and actual policy files against a current WELA package. Plan and Create require the reviewed fingerprint, explicit domain GUID/FQDN, exact writable DC, and a unique new name. Both source-backup sides must already be disabled. Creation records the new GUID, disables the empty candidate, rechecks its unlinked/empty state, and imports only into that GUID with protected receipts and final readback. It never links, enables, overwrites or deletes a GPO.
-Use explicit domain and DC parameters for every operation. Check connectivity/authority before creation and do not treat an access error as an absent GPO. Choose a fresh unique name. For example, in an authorized lab using Windows PowerShell and the GroupPolicy module:
-
-```powershell
-# Replace these with the explicitly reviewed lab domain, DC, genuine backup and name.
-$targetDomain = 'lab.example.test'
-$targetDc = 'dc01.lab.example.test'
-$genuineBackupRoot = 'C:\ReviewedGpoBackups'
-$reviewedBackupId = [guid]'11111111-1111-1111-1111-111111111111'
-$newName = 'WELA Audit - reviewed lab candidate'
-$receiptPath = 'C:\Review\new-gpo-receipt.json' # Must not already exist.
-
-if (Test-Path -LiteralPath $receiptPath) { throw 'Use a fresh receipt path.' }
-$existing = @(Get-GPO -All -Domain $targetDomain -Server $targetDc -ErrorAction Stop |
- Where-Object DisplayName -eq $newName)
-if ($existing.Count) { throw 'The target name already exists; stop without importing.' }
-
-# Do not specify a Starter GPO or pipe to New-GPLink.
-$created = New-GPO -Name $newName -Domain $targetDomain -Server $targetDc -ErrorAction Stop
-[pscustomobject]@{
- Domain = $targetDomain; Server = $targetDc; GpoGuid = $created.Id
- Name = $created.DisplayName; BackupId = $reviewedBackupId
- State = 'Created; import and verification pending'
-} | ConvertTo-Json | Out-File -LiteralPath $receiptPath -Encoding UTF8 -NoClobber -ErrorAction Stop
-
-# Recheck that this exact new GPO is empty and unlinked before importing.
-Get-GPOReport -Guid $created.Id -Domain $targetDomain -Server $targetDc -ReportType Xml
-```
-
-Stop and review the returned GUID, persisted receipt and actual report. Only after confirming that the exact new GPO remains empty and unlinked, execute this separate import step in the same reviewed session:
-
-```powershell
-Import-GPO -BackupId $reviewedBackupId -Path $genuineBackupRoot `
- -TargetGuid $created.Id -Domain $targetDomain -Server $targetDc -ErrorAction Stop
-Get-GPOReport -Guid $created.Id -Domain $targetDomain -Server $targetDc -ReportType Xml
-```
-
-`New-GPO` creates an unlinked object and refuses a duplicate name. `Import-GPO` imports into the returned **new GUID**, without `-CreateIfNeeded`, name-based targeting or `Restore-GPO`. Importing settings preserves the destination's existing security filtering and links; it does not supply an approved scope of application. Review the actual result, including exact settings and continued absence of links, before considering any later link. Do not assume the backup's security filtering or role metadata protects the new GPO. [New-GPO](https://learn.microsoft.com/en-us/powershell/module/grouppolicy/new-gpo?view=windowsserver2025-ps), [Import-GPO](https://learn.microsoft.com/en-us/powershell/module/grouppolicy/import-gpo?view=windowsserver2025-ps).
-
-If receipt persistence, import or readback fails, stop and retain the created GUID and evidence for manual review. Do not retry against an arbitrary existing GPO or automatically delete an object that another administrator may have changed or linked. This procedure is not a transaction or a lock against concurrent administrators; check the same DC immediately before each operation. Inspect both AD/SYSVOL versions and replication before proceeding beyond the unlinked candidate.
+Read that command's single-domain-forest restriction, native prerequisites, failure recovery and concurrency limits before use. The package commands remain offline and never invoke it implicitly. Successful candidate creation is separate from AD/SYSVOL replication and policy application.
## Deployment acceptance and recovery
@@ -71,4 +33,4 @@ Record the genuine backup, target GUID, actual GPMC settings, RSoP/GPO source ev
Rollback must be designed before linking. Preserve existing production GPOs and links throughout preparation. If testing fails, the policy owner should inspect and selectively reverse only the test changes, considering current links, authoritative settings and replication. Unlinking or deleting a GPO is not proof that all effective settings reverted. Retain evidence rather than blindly restoring an old whole-host policy snapshot. No audit-exhaustion test is required or provided.
-WELA's automated tests validate package contents and unchanged host settings only. Genuine GPO creation/import, absence of unintended policy settings, AD/SYSVOL replication, client/DC/AD CS application and event/collection evidence remain pending lab acceptance for issue #2. Other WELA controls and Sysmon are outside this package.
+WELA's package tests validate component contents and unchanged host settings. The separate creation tests also read a genuine Microsoft backup through native GPMC and verify broad-payload/workgroup refusals, while positive creation orchestration uses mocks. Genuine GPO creation/import, absence of unintended policy settings, AD/SYSVOL replication, client/DC/AD CS application and event/collection evidence remain pending lab acceptance for issue #2. Other WELA controls and Sysmon are outside this package.
diff --git a/scripts/Configuration.ps1 b/scripts/Configuration.ps1
index 1e7ead1f..3b93b4e6 100644
--- a/scripts/Configuration.ps1
+++ b/scripts/Configuration.ps1
@@ -108,7 +108,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", "native-channel-settings-only", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-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", "wmi-namespace-sacl-only", "ad-object-sacl-only", "windows-powershell-transcription-policy-only", "wef-source-configuration-only", "wec-collector-subscriptions-only", "audit-integrity-local-policy-only", "adcs-audit-settings-only", "disabled-unlinked-gpo-creation-only")]
[string]$Scope = "native-windows-configuration",
[string]$SuccessMessage = 'Configuration completed; all requested controls verified.')
if ($Context.PSObject.Properties['CustomProfileGuard']) {
diff --git a/scripts/GpoCreation.ps1 b/scripts/GpoCreation.ps1
new file mode 100644
index 00000000..b3f6dbe2
--- /dev/null
+++ b/scripts/GpoCreation.ps1
@@ -0,0 +1,495 @@
+# New, disabled, unlinked GPOs only. Never restore, overwrite, link, enable or delete a GPO.
+function Read-WelaGpoXml {
+ param([string]$Text)
+ $settings=New-Object Xml.XmlReaderSettings
+ $settings.DtdProcessing=[Xml.DtdProcessing]::Prohibit;$settings.XmlResolver=$null;$settings.MaxCharactersInDocument=4194304
+ $reader=[Xml.XmlReader]::Create([IO.StringReader]::new($Text),$settings)
+ try {$doc=New-Object Xml.XmlDocument;$doc.XmlResolver=$null;$doc.Load($reader);return ,$doc} finally {$reader.Dispose()}
+}
+function Get-WelaGpoChildren {
+ param($Node,[string[]]$Allowed,[string]$Namespace=$Node.NamespaceURI,[string[]]$Repeated=@())
+ $seen=@{}
+ foreach($child in $Node.ChildNodes) {
+ if ($child.NodeType -in @('Whitespace','Comment')) {continue}
+ if ($child.NodeType -ne 'Element' -or $child.NamespaceURI -cne $Namespace -or $child.LocalName -cnotin $Allowed -or ($seen.ContainsKey($child.LocalName) -and $child.LocalName -cnotin $Repeated)) {throw "Unexpected/duplicate GPO XML content: $($Node.LocalName)/$($child.LocalName)"}
+ $seen[$child.LocalName]=$true
+ }
+}
+function Get-WelaGpoText {
+ param($Node,[string]$Name,[string]$Namespace=$Node.NamespaceURI)
+ $found=@($Node.ChildNodes|Where-Object {$_.NodeType -eq 'Element' -and $_.LocalName -ceq $Name -and $_.NamespaceURI -ceq $Namespace})
+ if($found.Count -ne 1 -or @($found[0].ChildNodes|Where-Object NodeType -eq Element).Count) {throw "Missing/ambiguous scalar GPO XML field: $Name"}
+ [string]$found[0].InnerText
+}
+function Get-WelaGpoGuid {
+ param([string]$Value)
+ if($Value -notmatch '^\{?[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\}?$') {throw 'Expected an explicit GPO/domain/backup GUID.'}
+ ([guid]$Value).ToString('D').ToLowerInvariant()
+}
+function Get-WelaGpoAuditKey {
+ param([object[]]$Rows)
+ $seen=@{};$parts=@()
+ foreach($row in $Rows) {
+ $guid=Get-WelaGpoGuid $row.Guid
+ if($seen.ContainsKey($guid) -or [string]$row.Mask -cnotmatch '^[123]$') {throw 'Duplicate subcategory or unsupported audit mask in GPO content.'}
+ $seen[$guid]=$true;$parts+=$guid+'='+[string]$row.Mask
+ }
+ (@($parts|Sort-Object) -join ';')
+}
+function Get-WelaGpoExpectedKey {
+ param($Plan)
+ Get-WelaGpoAuditKey @($Plan.Controls|Where-Object Disposition -eq Exported|ForEach-Object {[pscustomobject]@{Guid=$_.Guid;Mask=$_.ExportMask}})
+}
+function Read-WelaGpoPolicyReport {
+ param([string]$Xml,[switch]$Blank,[switch]$AllowEnabled)
+ $doc=Read-WelaGpoXml $Xml;$root=$doc.DocumentElement;$ns='http://www.microsoft.com/GroupPolicy/Settings'
+ if($root.LocalName -cne 'GPO' -or $root.NamespaceURI -cne $ns) {throw 'Expected a native GPMC GPO report.'}
+ Get-WelaGpoChildren $root @('Identifier','Name','IncludeComments','CreatedTime','ModifiedTime','ReadTime','SecurityDescriptor','FilterDataAvailable','FilterName','FilterDescription','Computer','User','LinksTo') -Repeated LinksTo
+ $types='http://www.microsoft.com/GroupPolicy/Types'
+ $id=Get-WelaGpoGuid (Get-WelaGpoText $root.Identifier 'Identifier' $types)
+ $domain=Get-WelaGpoText $root.Identifier 'Domain' $types
+ $name=Get-WelaGpoText $root 'Name'
+ $rows=@();$precedence=0;$versions=@()
+ foreach($side in @('Computer','User')) {
+ $node=$root.$side
+ if(-not $node) {throw 'Incomplete GPO report sides.'}
+ Get-WelaGpoChildren $node @('VersionDirectory','VersionSysvol','Enabled','ExtensionData') -Repeated ExtensionData
+ $enabled=Get-WelaGpoText $node 'Enabled'
+ if($enabled -cnotin @('true','false') -or (-not $AllowEnabled -and $enabled -cne 'false')) {throw 'Both computer and user GPO settings must already be disabled.'}
+ $ad=Get-WelaGpoText $node 'VersionDirectory';$sysvol=Get-WelaGpoText $node 'VersionSysvol'
+ if($ad -notmatch '^\d{1,5}$' -or $sysvol -notmatch '^\d{1,5}$' -or [int]$ad -gt 65535 -or $ad -cne $sysvol) {throw 'GPO AD/SYSVOL versions are unknown or inconsistent.'}
+ if($Blank -and $ad -ne '0') {throw 'New GPO is not at a blank version.'}
+ $versions+=[int]$ad
+ foreach($extensionData in @($node.SelectNodes("*[local-name()='ExtensionData']"))) {
+ Get-WelaGpoChildren $extensionData @('Extension','Name')
+ $extensions=@($extensionData.SelectNodes("*[local-name()='Extension']"))
+ if($extensions.Count -ne 1 -or $side -eq 'User' -or $Blank) {throw 'User/blank GPO contains settings or ambiguous extensions.'}
+ $extension=$extensions[0]
+ foreach($setting in $extension.ChildNodes) {
+ if($setting.NodeType -in @('Whitespace','Comment')) {continue}
+ if($setting.LocalName -ceq 'AuditSetting' -and $setting.NamespaceURI -ceq ($ns+'/Auditing')) {
+ Get-WelaGpoChildren $setting @('PolicyTarget','SubcategoryName','SubcategoryGuid','SettingValue')
+ if((Get-WelaGpoText $setting 'PolicyTarget') -cne 'System') {throw 'Only system audit policy is supported.'}
+ $rows+=[pscustomobject]@{Guid=(Get-WelaGpoText $setting 'SubcategoryGuid');Mask=(Get-WelaGpoText $setting 'SettingValue')}
+ } elseif($setting.LocalName -ceq 'SecurityOptions' -and $setting.NamespaceURI -ceq ($ns+'/Security')) {
+ Get-WelaGpoChildren $setting @('KeyName','SettingNumber','Display')
+ if((Get-WelaGpoText $setting 'KeyName') -ine 'MACHINE\System\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy' -or (Get-WelaGpoText $setting 'SettingNumber') -cne '1') {throw 'Unrelated security setting in GPO report.'}
+ $precedence++
+ } else {throw "Unsupported GPO report policy extension: $($setting.NamespaceURI)/$($setting.LocalName)"}
+ }
+ }
+ }
+ if(($Blank -and ($rows.Count -or $precedence)) -or (-not $Blank -and ($precedence -ne 1 -or -not $rows.Count))) {throw 'GPO report lacks the exact selected audit/precedence policy.'}
+ if($root.FilterName -or $root.FilterDescription) {throw 'WMI-filtered source/target GPOs are unsupported.'}
+ [pscustomobject]@{Id=$id;Domain=$domain;Name=$name;AuditKey=(Get-WelaGpoAuditKey $rows);ComputerVersion=$versions[0];UserVersion=$versions[1];Links=@($root.SelectNodes("*[local-name()='LinksTo']")).Count;Disabled=($root.Computer.Enabled -ceq 'false' -and $root.User.Enabled -ceq 'false')}
+}
+function Test-WelaGpoPayload {
+ param([string]$AuditText,[string]$TemplateText,[string]$ExpectedKey)
+ $csv=@($AuditText|ConvertFrom-Csv -ErrorAction Stop)
+ $header='Machine Name|Policy Target|Subcategory|Subcategory GUID|Inclusion Setting|Exclusion Setting|Setting Value'
+ if(-not $csv.Count -or ($csv[0].PSObject.Properties.Name -join '|') -cne $header) {throw 'Unsupported native audit.csv schema.'}
+ $rows=@()
+ foreach($row in $csv) {
+ if($row.'Machine Name' -or $row.'Policy Target' -cne 'System' -or $row.'Exclusion Setting') {throw 'Per-user, machine-targeted or exclusion audit rows are unsupported.'}
+ $rows+=[pscustomobject]@{Guid=$row.'Subcategory GUID';Mask=$row.'Setting Value'}
+ }
+ if((Get-WelaGpoAuditKey $rows) -cne $ExpectedKey) {throw 'Actual audit.csv does not match the reviewed package.'}
+ $section='';$sections=@{};$values=@{}
+ foreach($raw in ($TemplateText -split '\r?\n')) {
+ $line=$raw.Trim().TrimStart([char]0xFEFF)
+ if(-not $line -or $line.StartsWith(';')) {continue}
+ if($line -match '^\[([^\]]+)\]$') {
+ $section=$matches[1]
+ if($sections.ContainsKey($section) -or $section -notin @('Unicode','Version','Registry Values','System Access','Event Audit','Privilege Rights','Registry Keys','File Security','Service General Setting')) {throw 'Unknown/duplicate security template section.'}
+ $sections[$section]=$true;continue
+ }
+ $key=$section+'|'+($line -split '=',2)[0].Trim()
+ if($values.ContainsKey($key)) {throw 'Duplicate security template setting.'};$values[$key]=$line
+ $accepted=switch($section) {
+ Unicode {$line -match '^Unicode\s*=\s*yes$'}
+ Version {$line -match '^(signature\s*=\s*"\$CHICAGO\$"|Revision\s*=\s*1)$'}
+ 'Registry Values' {$line -match '^MACHINE\\System\\CurrentControlSet\\Control\\Lsa\\SCENoApplyLegacyAuditPolicy\s*=\s*4\s*,\s*1$'}
+ default {$false}
+ }
+ if(-not $accepted) {throw 'Actual security template contains unrelated or mistyped settings.'}
+ }
+ if($values.Count -ne 4 -or -not $values.ContainsKey('Registry Values|MACHINE\System\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy')) {throw 'Security template must contain only typed DWORD audit precedence and its header.'}
+}
+function Get-WelaGpoInventory {
+ param([string]$Root,[switch]$Live)
+ $prefix=$Root.TrimEnd('\','/')+[IO.Path]::DirectorySeparatorChar
+ $files=@();$directories=@();$pending=New-Object 'System.Collections.Generic.Queue[string]';$pending.Enqueue($Root)
+ while($pending.Count) {
+ foreach($item in @(Get-ChildItem -LiteralPath $pending.Dequeue() -Force -ErrorAction Stop)) {
+ if(([int]$item.Attributes -band [int][IO.FileAttributes]::ReparsePoint) -or $item.Name -match ':') {throw 'GPO payload contains a reparse point or stream.'}
+ $relative=$item.FullName.Substring($prefix.Length).Replace('\','/').ToLowerInvariant()
+ if($item.PSIsContainer) {$directories+=$relative;$pending.Enqueue($item.FullName)}
+ else {
+ if($item.Length -gt 4194304) {throw 'GPO file exceeds 4 MiB.'}
+ $files+=[pscustomobject]@{Path=$relative;Length=$item.Length;Sha256=(Get-FileHash -LiteralPath $item.FullName -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant();FullPath=$item.FullName}
+ }
+ if($files.Count+$directories.Count -gt 100) {throw 'GPO inventory exceeds the narrow policy limit.'}
+ }
+ }
+ [pscustomobject]@{Files=$files;Directories=$directories}
+}
+function Test-WelaGpoInventory {
+ param($Inventory,[switch]$Live,[switch]$Blank)
+ $dirs=@('machine','user','machine/applications','machine/microsoft','machine/microsoft/windows nt','machine/microsoft/windows nt/audit','machine/microsoft/windows nt/secedit','machine/scripts','machine/scripts/startup','machine/scripts/shutdown','user/applications','user/scripts','user/scripts/logon','user/scripts/logoff')
+ $payload=@('machine/microsoft/windows nt/audit/audit.csv','machine/microsoft/windows nt/secedit/gpttmpl.inf')
+ $expected=if($Live) {if($Blank){@('gpt.ini')}else{@('gpt.ini')+$payload}} else {@('backup.xml','bkupinfo.xml','gpreport.xml')+@($payload|ForEach-Object {'domainsysvol/gpo/'+$_})}
+ $allowedDirs=if($Live){$dirs}else{@('domainsysvol','domainsysvol/gpo')+@($dirs|ForEach-Object {'domainsysvol/gpo/'+$_})}
+ if(@($Inventory.Directories|Where-Object {$_ -notin $allowedDirs}).Count -or @($Inventory.Files|Where-Object Path -notin $expected).Count -or $Inventory.Files.Count -ne $expected.Count) {throw 'Backup/SYSVOL must contain exactly the narrow audit payload and native metadata; unknown files/directories/settings are refused.'}
+}
+function Assert-WelaGpoBackupMetadata {
+ param([string]$Xml,[string]$BackupInfoXml,[string]$BackupId,$Report,$Inventory)
+ $doc=Read-WelaGpoXml $Xml;$ns='http://www.microsoft.com/GroupPolicy/GPOOperations';$root=$doc.DocumentElement
+ if($root.LocalName -cne 'GroupPolicyBackupScheme' -or $root.NamespaceURI -cne $ns -or $root.GetAttribute('version',$ns) -cne '2.0' -or $root.GetAttribute('type',$ns) -cne 'GroupPolicyBackupTemplate') {throw 'Unsupported native backup format.'}
+ Get-WelaGpoChildren $root @('GroupPolicyObject');$gpo=$root.GroupPolicyObject
+ Get-WelaGpoChildren $gpo @('SecurityGroups','FilePaths','GroupPolicyCoreSettings','GroupPolicyExtension') -Repeated GroupPolicyExtension
+ if(@($gpo.FilePaths.ChildNodes|Where-Object NodeType -eq Element).Count) {throw 'Backup path migration is unsupported.'}
+ $core=$gpo.GroupPolicyCoreSettings
+ Get-WelaGpoChildren $core @('ID','Domain','SecurityDescriptor','DisplayName','Options','UserVersionNumber','MachineVersionNumber','MachineExtensionGuids','UserExtensionGuids','WMIFilter')
+ if((Get-WelaGpoGuid (Get-WelaGpoText $core 'ID')) -ne $Report.Id -or (Get-WelaGpoText $core 'Domain') -ine $Report.Domain -or (Get-WelaGpoText $core 'DisplayName') -cne $Report.Name -or (Get-WelaGpoText $core 'Options') -cne '3' -or (Get-WelaGpoText $core 'UserExtensionGuids') -or (Get-WelaGpoText $core 'WMIFilter')) {throw 'Backup identity, disabled flags, user extensions or WMI filter differ from the reviewed source.'}
+ foreach($side in @('Machine','User')) {
+ $v=Get-WelaGpoText $core ($side+'VersionNumber');$value=[uint32]0
+ if(-not [uint32]::TryParse($v,[ref]$value) -or ($value -band 65535) -ne ($value -shr 16)) {throw 'Backup core version halves are inconsistent.'}
+ $expected=if($side -eq 'Machine'){$Report.ComputerVersion}else{$Report.UserVersion}
+ if(($value -band 65535) -ne $expected) {throw 'Backup core/report version mismatch.'}
+ }
+ $extensionText=Get-WelaGpoText $core 'MachineExtensionGuids'
+ $allowedGuids=@('827d319e-6eac-11d2-a4ea-00c04f79f83a','803e14a0-b4fb-11d0-a0d0-00a0c90f574b','f3ccc681-b74c-4060-9f26-cd84525dca2a','0f3f3735-573d-9804-99e4-ab2a69ba5fd4')
+ if($extensionText -notmatch '^(\[(\{[0-9A-Fa-f-]{36}\}){2,3}\]){2}$') {throw 'Unsupported machine extension registration.'}
+ $registered=@([regex]::Matches($extensionText,'\{([^}]+)\}')|ForEach-Object {$_.Groups[1].Value.ToLowerInvariant()})
+ if(@($registered|Where-Object {$_ -notin $allowedGuids}).Count -or $registered -notcontains $allowedGuids[0] -or $registered -notcontains $allowedGuids[2]) {throw 'Unrelated machine extension registration.'}
+ $registeredCses=@{}
+ foreach($group in [regex]::Matches($extensionText,'\[([^\]]+)\]')) {
+ $parts=@([regex]::Matches($group.Groups[1].Value,'\{([^}]+)\}')|ForEach-Object {$_.Groups[1].Value.ToLowerInvariant()})
+ $cse=$parts[0];$tools=if($cse -eq $allowedGuids[0]){@($allowedGuids[1])}elseif($cse -eq $allowedGuids[2]){@($allowedGuids[3],$allowedGuids[1])}else{throw 'Unsupported client-side extension position.'}
+ if($registeredCses.ContainsKey($cse) -or @($parts[1..($parts.Count-1)]|Where-Object {$_ -notin $tools}).Count -or @($parts|Select-Object -Unique).Count -ne $parts.Count) {throw 'Unexpected extension/tool registration.'}
+ $registeredCses[$cse]=$true
+ }
+ $seenFiles=@{};$seenExtensions=@{}
+ foreach($extension in @($gpo.SelectNodes("*[local-name()='GroupPolicyExtension']"))) {
+ $id=Get-WelaGpoGuid $extension.GetAttribute('ID',$ns)
+ if($seenExtensions.ContainsKey($id)) {throw 'Duplicate native backup extension.'};$seenExtensions[$id]=$true
+ if($id -notin @('827d319e-6eac-11d2-a4ea-00c04f79f83a','f3ccc681-b74c-4060-9f26-cd84525dca2a','f15c46cd-82a0-4c2d-a210-5d0d3182a418','35378eac-683f-11d2-a89a-00c04fbbcfa2')) {throw 'Unknown native backup extension.'}
+ Get-WelaGpoChildren $extension @('FSObjectFile','FSObjectDir') -Repeated @('FSObjectFile','FSObjectDir')
+ foreach($node in $extension.ChildNodes|Where-Object NodeType -eq Element) {
+ foreach($attribute in $node.Attributes) {if($attribute.NamespaceURI -cne $ns -or $attribute.LocalName -cnotin @('Path','SourceExpandedPath','Location','ReEvaluateFunction')) {throw 'Unknown backup filesystem directive.'}}
+ if(@($node.ChildNodes|Where-Object NodeType -eq Element).Count) {throw 'Nested filesystem directives are unsupported.'}
+ $location=$node.GetAttribute('Location',$ns).Replace('\','/').ToLowerInvariant()
+ $path=$node.GetAttribute('Path',$ns).Replace('\','/').ToLowerInvariant()
+ if($id -eq '35378eac-683f-11d2-a89a-00c04fbbcfa2') {
+ # Genuine GPMC security-only backups retain this absent legacy-ADM
+ # placeholder. It is not Registry.pol or a registered registry policy CSE.
+ $sourcePath=$node.GetAttribute('SourceExpandedPath',$ns)
+ $suffix='\sysvol\'+$Report.Domain+'\Policies\{'+$Report.Id+'}\Adm\*.*'
+ if($node.LocalName -cne 'FSObjectFile' -or $path -cne '%gpo_fspath%/adm/*.*' -or $node.HasAttribute('Location',$ns) -or $node.HasAttribute('ReEvaluateFunction',$ns) -or -not $sourcePath.StartsWith('\\') -or -not $sourcePath.EndsWith($suffix,[StringComparison]::OrdinalIgnoreCase)) {throw 'Only the absent native legacy-ADM placeholder is allowed in the Registry backup extension.'}
+ continue
+ }
+ if(-not $location.StartsWith('domainsysvol/gpo/machine/')) {throw 'Only explicit machine-relative backup paths are supported.'}
+ $relative=$location.Substring('domainsysvol/gpo/machine/'.Length)
+ if($path -cne ('%gpo_mach_fspath%/'+$relative)) {throw 'Backup filesystem path/location mismatch.'}
+ $sourcePath=$node.GetAttribute('SourceExpandedPath',$ns)
+ if($sourcePath -notmatch '^\\\\[^\\]+\\sysvol\\[^\\]+\\Policies\\\{[0-9A-Fa-f-]{36}\}\\Machine\\' -or -not $sourcePath.EndsWith(('\sysvol\'+$Report.Domain+'\Policies\{'+$Report.Id+'}\Machine\'+$relative.Replace('/','\')),[StringComparison]::OrdinalIgnoreCase)) {throw 'Unsupported source filesystem reference.'}
+ $reEvaluate=$node.GetAttribute('ReEvaluateFunction',$ns)
+ if($reEvaluate -and ($relative -ne 'microsoft/windows nt/secedit/gpttmpl.inf' -or $reEvaluate -cne 'SecurityValidateSettings')) {throw 'Unknown native backup callback.'}
+ if($node.LocalName -eq 'FSObjectDir') {if($location -notin $Inventory.Directories) {throw 'Backup references a missing/unknown directory.'}}
+ else {if($location -notin $Inventory.Files.Path -or $seenFiles.ContainsKey($location)) {throw 'Backup references a missing/duplicate/unknown file.'};$seenFiles[$location]=$true}
+ }
+ }
+ if($seenFiles.Count -ne 2) {throw 'Backup must reference exactly its two policy payloads.'}
+ $info=(Read-WelaGpoXml $BackupInfoXml).DocumentElement;$ins='http://www.microsoft.com/GroupPolicy/GPOOperations/Manifest'
+ if($info.LocalName -cne 'BackupInst' -or $info.NamespaceURI -cne $ins) {throw 'Unknown backup instance metadata.'}
+ Get-WelaGpoChildren $info @('GPOGuid','GPODomain','GPODomainGuid','GPODomainController','BackupTime','ID','Comment','GPODisplayName')
+ if((Get-WelaGpoGuid (Get-WelaGpoText $info 'ID')) -ne $BackupId -or (Get-WelaGpoGuid (Get-WelaGpoText $info 'GPOGuid')) -ne $Report.Id -or (Get-WelaGpoText $info 'GPODomain') -ine $Report.Domain -or (Get-WelaGpoText $info 'GPODisplayName') -cne $Report.Name) {throw 'Backup instance ID is not the source GPO ID, or metadata identities differ.'}
+}
+function Read-WelaGpoCreateConfig {
+ param([string]$Path)
+ $full=Resolve-WelaEvtxPath $Path
+ if((Get-Item -LiteralPath $full -ErrorAction Stop).Length -gt 65536) {throw 'GPO creation config exceeds 64 KiB.'}
+ $text=[IO.File]::ReadAllText($full);$config=ConvertFrom-WelaEvtxJson $text
+ Assert-WelaEvtxObject $config @('SchemaVersion','PackagePath','BackupRoot','BackupId','Domain','DomainGuid','Dc','Name','ReviewedSha256')
+ if(($config.SchemaVersion -isnot [int] -and $config.SchemaVersion -isnot [long]) -or $config.SchemaVersion -ne 1) {throw 'Unsupported GPO creation config schema.'}
+ foreach($field in @('PackagePath','BackupRoot','BackupId','Domain','DomainGuid','Dc','Name','ReviewedSha256')) {if($config.$field -isnot [string]) {throw "Expected a string: $field"}}
+ foreach($field in @('Domain','Dc')) {
+ $address=$null
+ if($config.$field -notmatch '^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$' -or [Net.IPAddress]::TryParse($config.$field,[ref]$address)) {throw 'Domain and DC must be exact DNS names, not IP addresses, URLs or aliases.'}
+ }
+ $config.BackupId=Get-WelaGpoGuid $config.BackupId;$config.DomainGuid=Get-WelaGpoGuid $config.DomainGuid
+ if($config.Name -notmatch '^[A-Za-z0-9][A-Za-z0-9 _.()-]{0,126}[A-Za-z0-9)]$' -or $config.Name -match '^Default (Domain|Domain Controllers) Policy$') {throw 'Use a unique, plain 2-128 character GPO name; default policies are forbidden.'}
+ if($config.ReviewedSha256 -and $config.ReviewedSha256 -cnotmatch '^[a-f0-9]{64}$') {throw 'ReviewedSha256 must be empty for Review or an exact lowercase SHA-256.'}
+ foreach($field in @('PackagePath','BackupRoot')) {
+ $pathValue=$config.$field
+ if(-not [IO.Path]::IsPathRooted($pathValue)) {$pathValue=Join-Path (Split-Path $full -Parent) $pathValue}
+ $config.$field=Resolve-WelaEvtxPath $pathValue
+ }
+ [pscustomobject]@{Config=$config;Path=$full;Sha256=(Get-WelaGpoBytesHash ([IO.File]::ReadAllBytes($full)))}
+}
+function Assert-WelaGpmResult {
+ param($Result)
+ if($null -eq $Result -or -not [Runtime.InteropServices.Marshal]::IsComObject($Result)) {throw 'Expected a native GPMC result, not a deserialized or fabricated status.'}
+ # HRESULT S_OK is normally projected as void; failure HRESULTs throw COMException.
+ # If the interop projection exposes a value, only typed integral S_OK is accepted.
+ $status=$Result.OverallStatus()
+ if($null -ne $status -and (($status -isnot [int] -and $status -isnot [long]) -or $status -ne 0)) {throw 'Native GPMC OverallStatus did not report S_OK.'}
+}
+function New-WelaGpm {
+ if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {throw 'GPO creation/review requires native Windows GPMC; no domain operation was attempted.'}
+ New-Object -ComObject GPMgmt.GPM -ErrorAction Stop
+}
+function Get-WelaGpoNativeBackup {
+ param([string]$Root,[string]$Id)
+ $gpm=New-WelaGpm;$constants=$gpm.GetConstants();$backup=$gpm.GetBackupDir($Root).GetBackup('{'+$Id+'}')
+ if((Get-WelaGpoGuid ([string]$backup.ID)) -ne $Id) {throw 'GPMC returned another backup instance.'}
+ $result=$backup.GenerateReport($constants.ReportXML)
+ Assert-WelaGpmResult $result
+ if($result.Result -isnot [string]) {throw 'Native GPMC backup report was not XML text.'}
+ [pscustomobject]@{Gpm=$gpm;Backup=$backup;Xml=[string]::Concat($result.Result);Id=$Id}
+}
+function Get-WelaGpoCreateInput {
+ param($Config)
+ $null=ConvertFrom-WelaEvtxJson ([IO.File]::ReadAllText((Join-Path $Config.PackagePath 'manifest.json')))
+ $package=Test-WelaGpoPackage $Config.PackagePath
+ $folder=Join-Path $Config.BackupRoot ('{'+$Config.BackupId+'}')
+ $folder=Resolve-WelaEvtxPath $folder
+ if(-not (Test-Path -LiteralPath $folder -PathType Container)) {throw 'Selected backup instance directory does not exist; specify the backup ID, not its source GPO ID.'}
+ $inventory=Get-WelaGpoInventory $folder;Test-WelaGpoInventory $inventory
+ $files=@{};foreach($file in $inventory.Files){$files[$file.Path]=$file.FullPath}
+ $report=Read-WelaGpoPolicyReport ([IO.File]::ReadAllText($files['gpreport.xml']))
+ $key=Get-WelaGpoExpectedKey $package.Plan
+ if($report.AuditKey -cne $key) {throw 'Cached GPMC report does not match the selected package.'}
+ Assert-WelaGpoBackupMetadata ([IO.File]::ReadAllText($files['backup.xml'])) ([IO.File]::ReadAllText($files['bkupinfo.xml'])) $Config.BackupId $report $inventory
+ Test-WelaGpoPayload ([IO.File]::ReadAllText($files['domainsysvol/gpo/machine/microsoft/windows nt/audit/audit.csv'])) ([IO.File]::ReadAllText($files['domainsysvol/gpo/machine/microsoft/windows nt/secedit/gpttmpl.inf'])) $key
+ $native=Get-WelaGpoNativeBackup $Config.BackupRoot $Config.BackupId
+ $nativeReport=Read-WelaGpoPolicyReport $native.Xml
+ if(($nativeReport|ConvertTo-Json -Compress) -cne ($report|ConvertTo-Json -Compress)) {throw 'Native GPMC backup report differs from its validated metadata/content.'}
+ $controlFiles=@()
+ $manifestPath=Join-Path $Config.BackupRoot 'manifest.xml'
+ if(Test-Path -LiteralPath $manifestPath) {
+ $manifestPath=Resolve-WelaEvtxPath $manifestPath;$item=Get-Item -LiteralPath $manifestPath -ErrorAction Stop
+ if($item.PSIsContainer -or $item.Length -gt 4194304) {throw 'Invalid or oversized native backup manifest.'}
+ $controlFiles+=@([pscustomobject]@{Path='manifest.xml';FullPath=$manifestPath;Length=$item.Length;Sha256=(Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256).Hash.ToLowerInvariant()})
+ }
+ $fingerprints=@($inventory.Files|Sort-Object Path|ForEach-Object {$_.Path+'|'+$_.Length+'|'+$_.Sha256})
+ $packageInventory=Get-WelaGpoInventory $Config.PackagePath
+ $fingerprints+=@($packageInventory.Files|Sort-Object Path|ForEach-Object {'package/'+$_.Path+'|'+$_.Length+'|'+$_.Sha256})
+ $fingerprints+=@($controlFiles|ForEach-Object {'native-root/'+$_.Path+'|'+$_.Length+'|'+$_.Sha256})
+ $fingerprints+=@($inventory.Directories|Sort-Object|ForEach-Object {'directory/'+$_})
+ $fingerprint=Get-WelaGpoBytesHash ([Text.Encoding]::UTF8.GetBytes(($fingerprints -join "`n")))
+ [pscustomobject]@{Fingerprint=$fingerprint;Package=$package;Inventory=$inventory;PackageInventory=$packageInventory;ControlFiles=$controlFiles;Source=$report;Native=$native;ExpectedKey=$key;NativeReportXml=$native.Xml}
+}
+function Open-WelaGpoCreationSession {
+ param($Config)
+ if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {throw 'GPO domain operations require Windows.'}
+ $computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
+ if($computer.PartOfDomain -ne $true -or $computer.Domain -ine $Config.Domain) {throw 'A host joined to the explicitly selected domain is required; workgroup/cross-domain execution is refused.'}
+ $session=Open-WelaAdSession $Config.Dc
+ try {
+ if(-not $session.Writable) {throw 'The pinned DC is read-only.'}
+ $expectedDn=(@($Config.Domain.Split('.')|ForEach-Object {'DC='+$_}) -join ',')
+ if($session.DomainDn -ine $expectedDn) {throw 'Pinned DC serves a different domain.'}
+ $entry=@(Search-WelaAdDirectory $session $session.DomainDn -Attributes @('objectGUID'))
+ if($entry.Count -ne 1 -or ([guid]::new([byte[]](Get-WelaAdSingleValue $entry[0] 'objectGUID'))).ToString('D') -ine $Config.DomainGuid) {throw 'Actual domain GUID differs from the reviewed identity.'}
+ # A cross-domain GPO link is possible. Refuse multi-domain forests instead of claiming
+ # a forest-wide negative from the pinned DC's one domain partition.
+ $domains=@(Search-WelaAdDirectory $session ('CN=Partitions,'+$session.ConfigurationDn) '(&(objectClass=crossRef)(systemFlags:1.2.840.113556.1.4.803:=2))' Subtree @('nCName'))
+ if($domains.Count -ne 1 -or (Get-WelaAdSingleValue $domains[0] 'nCName') -ine $session.DomainDn) {throw 'Only a verified single-domain forest is supported for complete domain/site link checks.'}
+ $gpm=New-WelaGpm;$domain=$gpm.GetDomain($Config.Domain,$Config.Dc,0)
+ [pscustomobject]@{Ad=$session;Gpm=$gpm;Domain=$domain;Identity=[Security.Principal.WindowsIdentity]::GetCurrent().User.Value;DomainGuid=$Config.DomainGuid;Dc=$Config.Dc}
+ } catch {$session.Connection.Dispose();throw}
+}
+function Close-WelaGpoCreationSession {param($Session) if($Session -and $Session.Ad){$Session.Ad.Connection.Dispose()}}
+function Get-WelaGpoNameMatches {
+ param($Session,[string]$Name)
+ # Config name grammar excludes LDAP-filter metacharacters except parentheses; encode all five.
+ $escaped=$Name.Replace('\','\5c').Replace('*','\2a').Replace('(','\28').Replace(')','\29').Replace([string][char]0,'\00')
+ @(Search-WelaAdDirectory $Session.Ad ('CN=Policies,CN=System,'+$Session.Ad.DomainDn) ('(&(objectClass=groupPolicyContainer)(displayName='+$escaped+'))') OneLevel @('objectGUID','displayName'))
+}
+function Get-WelaGpoNativeTarget {
+ param($Session,$Config,[string]$Id,[switch]$Blank,[switch]$AllowEnabled)
+ $gpo=$Session.Domain.GetGPO('{'+$Id+'}')
+ if((Get-WelaGpoGuid ([string]$gpo.ID)) -ne $Id -or [string]$gpo.DisplayName -cne $Config.Name -or (-not $AllowEnabled -and ($gpo.IsComputerEnabled() -ne $false -or $gpo.IsUserEnabled() -ne $false)) -or $gpo.IsACLConsistent() -ne $true) {throw 'New GPO identity, disabled status or ACL consistency changed.'}
+ $result=$gpo.GenerateReport($Session.Gpm.GetConstants().ReportXML);Assert-WelaGpmResult $result
+ $xml=[string]::Concat($result.Result);$report=Read-WelaGpoPolicyReport $xml -Blank:$Blank -AllowEnabled:$AllowEnabled
+ if($report.Id -ne $Id -or $report.Domain -ine $Config.Domain -or $report.Name -cne $Config.Name -or $report.Links) {throw 'Native target report identity or links changed.'}
+ foreach($base in @($Session.Ad.DomainDn,('CN=Sites,'+$Session.Ad.ConfigurationDn))) {
+ $links=@(Search-WelaAdDirectory $Session.Ad $base ('(gPLink=*{'+$Id+'}*)') Subtree @('gPLink'))
+ if($links.Count) {throw 'A domain/OU/site link to the new GPO appeared; no further import is allowed.'}
+ }
+ $dn='CN={'+$Id+'},CN=Policies,CN=System,'+$Session.Ad.DomainDn
+ $entry=@(Search-WelaAdDirectory $Session.Ad $dn -Attributes @('objectGUID','displayName','description','flags','versionNumber','gPCWQLFilter','gPCMachineExtensionNames','gPCUserExtensionNames','gPCFileSysPath','uSNChanged'))
+ if($entry.Count -ne 1) {throw 'Target GPO AD object is missing/ambiguous.'}
+ $e=$entry[0];$flags=Get-WelaAdSingleValue $e 'flags';$version=Get-WelaAdSingleValue $e 'versionNumber'
+ if(($flags -cnotin @('0','1','2','3')) -or (-not $AllowEnabled -and $flags -cne '3') -or $version -notmatch '^\d+$' -or [uint32]$version -ne (([uint32]$report.UserVersion -shl 16)+[uint32]$report.ComputerVersion) -or $e.Values['gPCWQLFilter'] -or $e.Values['gPCUserExtensionNames']) {throw 'Target flags, versions or filters changed.'}
+ if($Blank -and ($e.Values['gPCMachineExtensionNames'] -or $version -ne '0')) {throw 'Fresh target already contains extension settings.'}
+ $nativePath=Get-WelaAdSingleValue $e 'gPCFileSysPath'
+ $expectedPath='\\'+$Config.Domain+'\SysVol\'+$Config.Domain+'\Policies\{'+$Id+'}'
+ if($nativePath -ine $expectedPath) {throw 'Unexpected target SYSVOL policy path.'}
+ $path='\\'+$Config.Dc+'\SYSVOL\'+$Config.Domain+'\Policies\{'+$Id+'}'
+ $inventory=Get-WelaGpoInventory $path -Live;Test-WelaGpoInventory $inventory -Live -Blank:$Blank
+ $iniFile=@($inventory.Files|Where-Object Path -eq 'gpt.ini')[0]
+ $ini=[IO.File]::ReadAllText($iniFile.FullPath)
+ if($ini -notmatch '(?im)^Version\s*=\s*(\d+)\s*$' -or [uint32]$matches[1] -ne [uint32]$version) {throw 'Live GPT.INI version differs from AD/report.'}
+ if(-not $Blank) {
+ $audit=@($inventory.Files|Where-Object {$_.Path -like '*/audit.csv'})[0];$inf=@($inventory.Files|Where-Object {$_.Path -like '*/gpttmpl.inf'})[0]
+ Test-WelaGpoPayload ([IO.File]::ReadAllText($audit.FullPath)) ([IO.File]::ReadAllText($inf.FullPath)) $report.AuditKey
+ }
+ # Preserve the permission descriptor rendered by native GPMC; no ACL writes.
+ $reportDoc=Read-WelaGpoXml $xml
+ $sddlNodes=@($reportDoc.SelectNodes("//*[local-name()='SecurityDescriptor']/*[local-name()='SDDL' and namespace-uri()='http://www.microsoft.com/GroupPolicy/Types/Security']"))
+ if($sddlNodes.Count -ne 1 -or -not $sddlNodes[0].InnerText) {throw 'Native GPO permission descriptor is unknown.'}
+ [pscustomobject]@{Id=$Id;Name=$report.Name;Description=(Get-WelaAdSingleValue $e 'description');Disabled=$report.Disabled;Links=0;AuditKey=$report.AuditKey;ComputerVersion=$report.ComputerVersion;UserVersion=$report.UserVersion;Permissions=$sddlNodes[0].InnerText;Usn=(Get-WelaAdSingleValue $e 'uSNChanged');ObjectGuid=([guid]::new([byte[]](Get-WelaAdSingleValue $e 'objectGUID'))).ToString('D');Xml=$xml;Inventory=@($inventory.Files|Select-Object Path,Length,Sha256)}
+}
+function New-WelaGpoNativeTarget {
+ param($Session,$Config,[string]$Marker)
+ Import-Module GroupPolicy -ErrorAction Stop
+ # New-GPO rejects a duplicate display name. No -CreateIfNeeded, name fallback or pre-existing GUID.
+ $created=New-GPO -Name $Config.Name -Comment $Marker -Domain $Config.Domain -Server $Config.Dc -ErrorAction Stop
+ Get-WelaGpoGuid ([string]$created.Id)
+}
+function Disable-WelaGpoNativeTarget {
+ param($Session,$Config,[string]$Id,[string]$Marker)
+ $initial=Get-WelaGpoNativeTarget $Session $Config $Id -Blank -AllowEnabled
+ if($initial.Description -cne $Marker) {throw 'New GPO ownership marker changed before disabling.'}
+ $gpo=$Session.Domain.GetGPO('{'+$Id+'}')
+ if((Get-WelaGpoGuid ([string]$gpo.ID)) -ne $Id) {throw 'GPMC returned another new GPO.'}
+ $null=$gpo.SetComputerEnabled($false)
+ $fresh=Get-WelaGpoNativeTarget $Session $Config $Id -Blank -AllowEnabled
+ if($fresh.Description -cne $Marker -or $fresh.ObjectGuid -cne $initial.ObjectGuid -or $fresh.Permissions -cne $initial.Permissions) {throw 'New GPO changed while disabling its empty settings.'}
+ $null=$gpo.SetUserEnabled($false)
+}
+function Import-WelaGpoNativeTarget {
+ param($Session,[string]$Id,$Backup)
+ $gpo=$Session.Domain.GetGPO('{'+$Id+'}')
+ $result=$gpo.Import(0,$Backup)
+ Assert-WelaGpmResult $result
+}
+function Write-WelaGpoReceipt {
+ param([string]$Root,[string]$Name,$Value)
+ $bytes=[Text.UTF8Encoding]::new($false).GetBytes((ConvertTo-Json -InputObject $Value -Depth 20))
+ $stream=[IO.File]::Open((Join-Path $Root $Name),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
+ try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush($true)} finally {$stream.Dispose()}
+}
+function Protect-WelaGpoOutput {
+ param([string]$Path)
+ if([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {return}
+ $acl=New-Object Security.AccessControl.DirectorySecurity;$acl.SetAccessRuleProtection($true,$false)
+ foreach($sid in @([Security.Principal.WindowsIdentity]::GetCurrent().User.Value,'S-1-5-18','S-1-5-32-544')|Select-Object -Unique) {
+ $rule=New-Object Security.AccessControl.FileSystemAccessRule([Security.Principal.SecurityIdentifier]::new($sid),'FullControl','ContainerInherit,ObjectInherit','None','Allow');$acl.AddAccessRule($rule)
+ }
+ Set-Acl -LiteralPath $Path -AclObject $acl -ErrorAction Stop
+}
+function Copy-WelaGpoReviewedBackup {
+ param($InputState,[string]$Root,[string]$BackupId)
+ # Copy unchanged genuine bytes into the protected receipt directory. Never synthesize
+ # Backup.xml or alter an archived policy to make it pass validation.
+ $stage=Join-Path $Root 'reviewed-backup';$instance=Join-Path $stage ('{'+$BackupId+'}')
+ $null=New-Item -ItemType Directory -Path $instance -ErrorAction Stop
+ foreach($directory in $InputState.Inventory.Directories|Sort-Object Length) {
+ $null=New-Item -ItemType Directory -Path (Join-Path $instance $directory) -ErrorAction Stop
+ }
+ foreach($file in $InputState.Inventory.Files) {[IO.File]::Copy($file.FullPath,(Join-Path $instance $file.Path),$false)}
+ foreach($file in $InputState.ControlFiles) {[IO.File]::Copy($file.FullPath,(Join-Path $stage $file.Path),$false)}
+ $stage
+}
+function Assert-WelaGpoCreateSource {
+ param($State)
+ $fresh=Read-WelaGpoCreateConfig $State.ConfigSource.Path
+ if($fresh.Sha256 -cne $State.ConfigSource.Sha256) {throw 'Reviewed creation config changed.'}
+ $input=Get-WelaGpoCreateInput $fresh.Config
+ if($input.Fingerprint -cne $State.Input.Fingerprint) {throw 'Reviewed backup/package source changed.'}
+ $input
+}
+function Get-WelaGpoCreationState {
+ param($State)
+ $null=Assert-WelaGpoCreateSource $State
+ $session=Open-WelaGpoCreationSession $State.Config
+ try {
+ if($session.Identity -cne $State.OperatorSid) {throw 'Authenticated operator identity changed.'}
+ $matches=@(Get-WelaGpoNameMatches $session $State.Config.Name)
+ if(-not $State.Id) {if($matches.Count) {throw 'Requested new GPO name already exists; it will never be overwritten.'};return [pscustomobject]@{Exists=$false;DomainGuid=$session.DomainGuid;Dc=$session.Dc;OperatorSid=$session.Identity}}
+ if($matches.Count -ne 1) {throw 'Created GPO name is no longer unique.'}
+ $target=Get-WelaGpoNativeTarget $session $State.Config $State.Id
+ if($target.Description -cne $State.Marker -or $target.ObjectGuid -cne $State.Blank.ObjectGuid -or $target.Permissions -cne $State.Blank.Permissions -or $target.AuditKey -cne $State.Input.ExpectedKey -or $target.UserVersion -lt $State.Blank.UserVersion -or $target.ComputerVersion -le $State.Blank.ComputerVersion) {throw 'Imported target identity, permissions, content or versions differ from the approved candidate.'}
+ if($State.Verified -and ($target|ConvertTo-Json -Depth 15 -Compress) -cne ($State.Verified|ConvertTo-Json -Depth 15 -Compress)) {
+ # Native ReadTime changes every report. Compare only stable evidence below instead.
+ if((Get-WelaGpoTargetKey $target) -cne (Get-WelaGpoTargetKey $State.Verified)) {throw 'Created GPO changed after import verification.'}
+ }
+ if(-not $State.Verified){$State.Verified=$target}
+ [pscustomobject]@{Exists=$true;VerifiedCandidate=$true;Target=$target}
+ } finally {Close-WelaGpoCreationSession $session}
+}
+function Get-WelaGpoTargetKey {
+ param($Target)
+ $Target|Select-Object Id,Name,Description,Disabled,Links,AuditKey,ComputerVersion,UserVersion,Permissions,Usn,ObjectGuid,Inventory|ConvertTo-Json -Depth 12 -Compress
+}
+function Invoke-WelaGpoCreateCommand {
+ param([ValidateSet('Review','Plan','Create')][string]$Action='Review',[string]$ConfigPath,[switch]$Auto,[switch]$DryRun,[string]$BackupPath)
+ if($Action -ne 'Create' -and ($Auto -or $DryRun -or $BackupPath)) {throw 'Auto, DryRun and BackupPath require GpoCreateAction Create.'}
+ if($Action -eq 'Create' -and -not $BackupPath) {throw 'Create requires an explicit fresh local BackupPath for durable receipts.'}
+ if([string]::IsNullOrWhiteSpace($ConfigPath)) {throw 'GpoCreateConfigPath is required.'}
+ $source=Read-WelaGpoCreateConfig $ConfigPath;$config=$source.Config;$input=Get-WelaGpoCreateInput $config
+ if($Action -ne 'Review' -and $config.ReviewedSha256 -cne $input.Fingerprint) {throw 'Plan/Create require ReviewedSha256 from the reviewed native backup/package content.'}
+ $report=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaDisabledUnlinkedGpoCreation';Action=$Action;ExitCode=0;Status='ReviewedInputsOnly';ReviewedSha256=$input.Fingerprint;ConfigSha256=$source.Sha256;BackupId=$config.BackupId;SourceGpoId=$input.Source.Id;SourceName=$input.Source.Name;ObservedDomain=$null;Domain=$config.Domain;DomainGuid=$config.DomainGuid;Dc=$config.Dc;Name=$config.Name;Profile=$input.Package.Plan;CreatedGpoId=$null;Configuration=$null;DeploymentVerified=$false;SigmaEvtxCredit=0;Limits=@('Native Windows audit policy and precedence only; Sysmon excluded.','No link, enable, assignment, client refresh, existing-GPO overwrite, deletion or automatic rollback.','Only a single-domain forest is supported; domain/OU/site links are freshly checked on the pinned writable DC.','Readback is scoped to the pinned DC and moment; replication, client application, events and positive real-domain acceptance remain separate.');OutputPath=$null}
+ if($Action -eq 'Review') {return $report}
+ $session=Open-WelaGpoCreationSession $config
+ try {
+ if(@(Get-WelaGpoNameMatches $session $config.Name).Count) {throw 'Requested GPO name already exists.'}
+ $sid=$session.Identity
+ $report.ObservedDomain=[pscustomobject]@{DomainGuid=$session.DomainGuid;PinnedWritableDc=$session.Dc;OperatorSid=$sid;ObservedUtc=[DateTime]::UtcNow.ToString('o');SingleDomainForest=$true}
+ } finally {Close-WelaGpoCreationSession $session}
+ $report.Status='PlanValidated';if($Action -eq 'Plan'){return $report}
+ $output=Resolve-WelaEvtxPath $BackupPath
+ if(Test-Path -LiteralPath $output) {throw 'Create requires a fresh output directory.'}
+ if(-not (Test-Path -LiteralPath (Split-Path $output -Parent) -PathType Container)) {throw 'Output parent must already exist.'}
+ $context=New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $output
+ $state=@{ConfigSource=$source;Config=$config;Input=$input;OperatorSid=$sid;Id=$null;Marker=('WELA disabled unlinked candidate '+[guid]::NewGuid().ToString('D'));Blank=$null;Verified=$null;Output=$output}
+ if(-not $DryRun) {Protect-WelaGpoOutput $output;Write-WelaGpoReceipt $output 'reviewed-plan.json' $report;$report.OutputPath=$output}
+ $read={param($s) Get-WelaGpoCreationState $s}
+ $compliant={param($current,$s) $current.Exists -and $current.VerifiedCandidate}
+ $apply={param($s)
+ $fresh=Assert-WelaGpoCreateSource $s
+ # Hold all reviewed inputs against write/delete during native import. Added entries are
+ # independently detected by full re-inventory immediately before mutation and after it.
+ $locks=New-Object 'System.Collections.Generic.List[IDisposable]';$session=$null
+ try {
+ foreach($path in @($s.ConfigSource.Path)+@($fresh.Inventory.Files.FullPath)+@($fresh.PackageInventory.Files.FullPath)+@($fresh.ControlFiles|ForEach-Object FullPath)) {$locks.Add([IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read))}
+ $fresh=Assert-WelaGpoCreateSource $s
+ $stage=Copy-WelaGpoReviewedBackup $fresh $s.Output $s.Config.BackupId
+ $stageConfig=$s.Config|Select-Object *;$stageConfig.BackupRoot=$stage
+ $staged=Get-WelaGpoCreateInput $stageConfig
+ if($staged.Fingerprint -cne $fresh.Fingerprint) {throw 'Protected backup copy differs from reviewed bytes.'}
+ foreach($path in @($staged.Inventory.Files.FullPath)+@($staged.ControlFiles|ForEach-Object FullPath)) {$locks.Add([IO.File]::Open($path,[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::Read))}
+ $session=Open-WelaGpoCreationSession $s.Config
+ if($session.Identity -cne $s.OperatorSid -or @(Get-WelaGpoNameMatches $session $s.Config.Name).Count) {throw 'New-GPO precondition changed.'}
+ Write-WelaGpoReceipt $s.Output 'creation-intent.json' ([ordered]@{Domain=$s.Config.Domain;Dc=$s.Config.Dc;DomainGuid=$s.Config.DomainGuid;Name=$s.Config.Name;Marker=$s.Marker;ReviewedSha256=$fresh.Fingerprint;OperatorSid=$s.OperatorSid;Recovery='If creation returns no GUID, search the exact name/comment on this DC. Never delete or change an unrelated GPO.'})
+ $s.Id=New-WelaGpoNativeTarget $session $s.Config $s.Marker
+ # Record identity before any import; failure leaves an empty, unlinked GPO. No deletion.
+ Write-WelaGpoReceipt $s.Output 'created-gpo.json' ([ordered]@{Id=$s.Id;DomainGuid=$s.Config.DomainGuid;Domain=$s.Config.Domain;Dc=$s.Config.Dc;Name=$s.Config.Name;Marker=$s.Marker;Status='CreatedEmpty_DisableAndImportNotYetVerified'})
+ Close-WelaGpoCreationSession $session;$session=Open-WelaGpoCreationSession $s.Config
+ if($session.Identity -cne $s.OperatorSid) {throw 'Operator identity changed before disabling.'}
+ Disable-WelaGpoNativeTarget $session $s.Config $s.Id $s.Marker
+ $s.Blank=Get-WelaGpoNativeTarget $session $s.Config $s.Id -Blank
+ if($s.Blank.Description -cne $s.Marker) {throw 'New GPO ownership marker changed.'}
+ Write-WelaGpoReceipt $s.Output 'blank-target.json' $s.Blank
+ $null=Assert-WelaGpoCreateSource $s
+ Close-WelaGpoCreationSession $session;$session=Open-WelaGpoCreationSession $s.Config
+ if($session.Identity -cne $s.OperatorSid -or @(Get-WelaGpoNameMatches $session $s.Config.Name).Count -ne 1) {throw 'Import identity/name precondition changed.'}
+ $blank=Get-WelaGpoNativeTarget $session $s.Config $s.Id -Blank
+ if((Get-WelaGpoTargetKey $blank) -cne (Get-WelaGpoTargetKey $s.Blank)) {throw 'Fresh blank target changed before import.'}
+ Write-WelaGpoReceipt $s.Output 'import-intent.json' ([ordered]@{Target=$blank;BackupId=$s.Config.BackupId;ReviewedSha256=$fresh.Fingerprint;Scope='Import only into this disabled, empty, unlinked new GUID; native GPMC flags 0, no migration table.'})
+ $stagedFresh=Get-WelaGpoCreateInput $stageConfig
+ if($stagedFresh.Fingerprint -cne $fresh.Fingerprint) {throw 'Protected backup changed before import.'}
+ Import-WelaGpoNativeTarget $session $s.Id $stagedFresh.Native.Backup
+ $null=Assert-WelaGpoCreateSource $s
+ } finally {Close-WelaGpoCreationSession $session;foreach($lock in $locks){$lock.Dispose()}}
+ }
+ Invoke-WelaConfigurationControl -Context $context -Id 'GPO/CreateDisabledUnlinked' -Kind 'NewDomainGpo' -Target @{Domain=$config.Domain;DomainGuid=$config.DomainGuid;Dc=$config.Dc;Name=$config.Name} -Desired @{ReviewedSha256=$input.Fingerprint;BothSidesDisabled=$true;Links=0} -Read $read -Compliant $compliant -Apply $apply -CallbackState $state -Description 'Create a NEW disabled unlinked audit-policy candidate. Failure retains its GUID and evidence; no automatic deletion.'
+ $configuration=Complete-WelaConfiguration -Context $context -Scope 'disabled-unlinked-gpo-creation-only' -SuccessMessage 'New disabled, unlinked GPO content verified on the selected DC; deployment remains unverified.'
+ $report.Configuration=$configuration;$report.ExitCode=$configuration.ExitCode;$report.CreatedGpoId=$state.Id
+ $report.Status=if($configuration.ExitCode){'Failed_ReviewRetainedReceipts'}elseif($DryRun){'DryRun_NoGpoCreated'}elseif($configuration.Skipped){'Declined_NoGpoCreated'}else{'DisabledUnlinkedCandidateVerified'}
+ if(-not $DryRun){Write-WelaGpoReceipt $output 'result.json' $report}
+ return $report
+}
diff --git a/tests/GpoCreation.Cli.Tests.ps1 b/tests/GpoCreation.Cli.Tests.ps1
new file mode 100644
index 00000000..17cc6ae8
--- /dev/null
+++ b/tests/GpoCreation.Cli.Tests.ps1
@@ -0,0 +1,21 @@
+# Child-process public dispatch regressions: no domain connection or policy writers.
+$ErrorActionPreference='Stop';$repo=Split-Path $PSScriptRoot -Parent
+$engine=(Get-Process -Id $PID).Path;$checks=0
+$cases=@(
+ @{Args=@('gpo-create','-Help');Code=0;Pattern='disabled, unlinked'},
+ @{Args=@('configure','-GpoCreateAction','Create','-Auto');Code=1;Pattern='creation options require'},
+ @{Args=@('gpo-create','-Help','-Profile','wela-2.2.0');Code=1;Pattern='only its dedicated'},
+ @{Args=@('gpo-create','-Help','-GpoAction','Export');Code=1;Pattern='only its dedicated'},
+ @{Args=@('gpo-create','-GpoCreateAction','Plan','-DryRun');Code=1;Pattern='DryRun is supported only'},
+ @{Args=@('gpo-create','-GpoCreateAction','Create');Code=1;Pattern='explicit fresh'},
+ @{Args=@('gpo-create','-GpoCreateAction','Review','-Auto');Code=1;Pattern='require GpoCreateAction'},
+ @{Args=@('gpo-create','-GpoCreateAction','Review','-ResultsPath','should-not-exist.json');Code=1;Pattern='only its dedicated'}
+)
+foreach($case in $cases){
+ $prior=$ErrorActionPreference
+ try {$ErrorActionPreference='Continue';$output=@(& $engine -NoLogo -NoProfile -NonInteractive -File (Join-Path $repo 'WELA.ps1') @($case.Args) 2>&1);$code=$LASTEXITCODE}
+ finally {$ErrorActionPreference=$prior}
+ if(($case.Code -eq 0 -and $code -ne 0) -or ($case.Code -ne 0 -and $code -eq 0) -or ($output -join "`n") -notmatch $case.Pattern){throw "CLI failure: $($case.Args -join ' ') -> $code / $($output -join ' ')"};$checks++
+}
+Write-Host "GPO creation public CLI: $checks checks passed."
+$global:LASTEXITCODE=0
diff --git a/tests/GpoCreation.Tests.ps1 b/tests/GpoCreation.Tests.ps1
new file mode 100644
index 00000000..6c63e659
--- /dev/null
+++ b/tests/GpoCreation.Tests.ps1
@@ -0,0 +1,145 @@
+# Synthetic orchestration fixtures. They never create/import a real domain GPO.
+$ErrorActionPreference='Stop'
+$repo=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/GpoAuditPackages.ps1')
+. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
+. (Join-Path $repo 'scripts/GpoCreation.ps1')
+$script:checks=0
+function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
+function Reject([scriptblock]$Code,[string]$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
+$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-gpo-create-'+[guid]::NewGuid().ToString('N'));$null=New-Item -ItemType Directory $temp
+function SaveText($Path,[string]$Text){[IO.File]::WriteAllText($Path,$Text,[Text.UTF8Encoding]::new($false))}
+function FreshPath {Join-Path $temp ([guid]::NewGuid().ToString('N'))}
+$script:backupId='aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa';$script:sourceId='bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb';$script:createdId='cccccccc-cccc-cccc-cccc-cccccccccccc'
+$script:plan=Get-WelaGpoPackagePlan wela-2.2.0 Client 26100
+function New-Fixture {
+ $root=FreshPath;$null=New-Item -ItemType Directory $root
+ $package=Join-Path $root 'package';$null=Export-WelaGpoPackage $script:plan $package
+ $backupRoot=Join-Path $root 'backups';$backup=Join-Path $backupRoot ('{'+$script:backupId+'}')
+ $audit=Join-Path $backup 'DomainSysvol/GPO/Machine/microsoft/windows nt/Audit';$inf=Join-Path $backup 'DomainSysvol/GPO/Machine/microsoft/windows nt/SecEdit'
+ $null=New-Item -ItemType Directory $audit -Force;$null=New-Item -ItemType Directory $inf -Force
+ Copy-Item (Join-Path $package 'audit.csv') (Join-Path $audit 'audit.csv');Copy-Item (Join-Path $package 'GptTmpl.inf') (Join-Path $inf 'GptTmpl.inf')
+ $settings='';foreach($row in $script:plan.Controls|Where-Object Disposition -eq Exported){$settings+='SystemLocalized display{'+$row.Guid+'}'+$row.ExportMask+''}
+ $report='{'+$script:sourceId+'}example.testAudit Source11false'+$settings+'Localized auditingMACHINE\System\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy1Localized nameSecurity00false'
+ SaveText (Join-Path $backup 'gpreport.xml') $report
+ $fs='';foreach($file in @('Audit\audit.csv','SecEdit\GptTmpl.inf')){
+ $rel='microsoft\windows nt\'+$file
+ $fs+=''
+ }
+ $xml='{'+$script:sourceId+'}example.test01 00Audit Source3065537[{827D319E-6EAC-11D2-A4EA-00C04F79F83A}{803E14A0-B4FB-11D0-A0D0-00A0C90F574B}][{F3CCC681-B74C-4060-9F26-CD84525DCA2A}{0F3F3735-573D-9804-99E4-AB2A69BA5FD4}]'+$fs+''
+ SaveText (Join-Path $backup 'Backup.xml') $xml
+ SaveText (Join-Path $backup 'bkupInfo.xml') ('{'+$script:backupId+'}{'+$script:sourceId+'}example.testAudit Source')
+ $config=[ordered]@{SchemaVersion=1;PackagePath=$package;BackupRoot=$backupRoot;BackupId=$script:backupId;Domain='example.test';DomainGuid='dddddddd-dddd-dddd-dddd-dddddddddddd';Dc='dc.example.test';Name='Reviewed Audit Candidate';ReviewedSha256=''}
+ $configPath=Join-Path $root 'create.json';SaveText $configPath ($config|ConvertTo-Json)
+ [pscustomobject]@{Root=$root;Package=$package;Backup=$backup;Config=$config;ConfigPath=$configPath;Report=$report}
+}
+# The only native backup adapter is mocked. Production XML/payload/package/inventory validation runs.
+function Get-WelaGpoNativeBackup {param($Root,$Id) [pscustomobject]@{Xml=[IO.File]::ReadAllText((Join-Path (Join-Path $Root ('{'+$Id+'}')) 'gpreport.xml'));Backup='FixtureNativeBackup';Id=$Id}}
+$script:nativeBackupAdapter=${function:Get-WelaGpoNativeBackup}
+function Ready($Fixture) {$r=Invoke-WelaGpoCreateCommand -ConfigPath $Fixture.ConfigPath;$Fixture.Config.ReviewedSha256=$r.ReviewedSha256;SaveText $Fixture.ConfigPath ($Fixture.Config|ConvertTo-Json);$r}
+function ResetNative {
+ $script:created=0;$script:disabled=0;$script:imported=0;$script:targetReads=0;$script:sessionReads=0;$script:marker='';$script:fault='';$script:exists=$false;$script:operator='S-1-5-21-1-2-3-1001'
+}
+function Open-WelaGpoCreationSession {param($Config) $script:sessionReads++;if($script:fault -eq 'domain' -or ($script:fault -eq 'domain-race' -and $script:sessionReads -ge 3)){throw 'Actual domain GUID changed'};[pscustomobject]@{Identity=$script:operator;DomainGuid=$Config.DomainGuid;Dc=$Config.Dc}}
+function Close-WelaGpoCreationSession {param($Session)}
+function Get-WelaGpoNameMatches {param($Session,$Name) if($script:exists -or $script:created){[pscustomobject]@{Name=$Name}}}
+function New-WelaGpoNativeTarget {param($Session,$Config,$Marker) $script:created++;$script:marker=$Marker;$script:createdId}
+function Disable-WelaGpoNativeTarget {param($Session,$Config,$Id,$Marker) Assert ($Id -eq $script:createdId -and $Marker -ceq $script:marker) 'Disable only returned GUID and ownership marker';$script:disabled++}
+function Import-WelaGpoNativeTarget {param($Session,$Id,$Backup) Assert ($Id -eq $script:createdId -and $script:disabled -eq 1 -and $Backup -eq 'FixtureNativeBackup') 'Import only own disabled GUID and validated backup';$script:imported++;if($script:fault -eq 'native-status'){throw 'Native GPMC OverallStatus failed'}}
+function Get-WelaGpoNativeTarget {
+ param($Session,$Config,$Id,[switch]$Blank,[switch]$AllowEnabled)
+ $script:targetReads++
+ if(($script:fault -eq 'target-race' -and $Blank -and $script:targetReads -eq 2) -or ($script:fault -eq 'linked' -and -not $Blank)){throw 'New GPO links/blank state changed'}
+ $key=if($Blank){''}else{Get-WelaGpoExpectedKey $script:plan}
+ if($script:fault -eq 'content' -and -not $Blank){$key='wrong'}
+ $permission=if($script:fault -eq 'acl' -and -not $Blank){'O:BAD'}else{'O:SYG:SYD:(A;;GA;;;SY)'}
+ $version=if($Blank){0}elseif($script:fault -eq 'final-drift' -and $script:targetReads -ge 4){2}else{1}
+ [pscustomobject]@{Id=$Id;Name=$Config.Name;Description=$script:marker;Disabled=$true;Links=0;AuditKey=$key;ComputerVersion=$version;UserVersion=0;Permissions=$permission;Usn=([string](10+$version));ObjectGuid='eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee';Xml='';Inventory=@()}
+}
+try {
+ $placeholder=''
+ $placeholderFixture=New-Fixture;$metadataPath=Join-Path $placeholderFixture.Backup 'Backup.xml'
+ $metadata=[IO.File]::ReadAllText($metadataPath).Replace('',$placeholder+'');SaveText $metadataPath $metadata
+ Assert ((Invoke-WelaGpoCreateCommand -ConfigPath $placeholderFixture.ConfigPath).Status -eq 'ReviewedInputsOnly') 'An exact absent legacy ADM placeholder from native GPMC is accepted without registry policy'
+ foreach($mutation in @('location','callback','registry','adm-file')) {
+ SaveText $metadataPath $metadata
+ switch($mutation) {
+ location {SaveText $metadataPath ($metadata.Replace('bkp:Path="%GPO_FSPATH%', 'bkp:Location="DomainSysvol\GPO\Adm" bkp:Path="%GPO_FSPATH%'))}
+ callback {SaveText $metadataPath ($metadata.Replace('bkp:Path="%GPO_FSPATH%', 'bkp:ReEvaluateFunction="Unknown" bkp:Path="%GPO_FSPATH%'))}
+ registry {SaveText $metadataPath ($metadata.Replace('%GPO_FSPATH%\Adm\*.*','%GPO_MACH_FSPATH%\registry.pol'))}
+ adm-file {$adm=Join-Path $placeholderFixture.Backup 'DomainSysvol/GPO/Adm';$null=New-Item -ItemType Directory $adm;SaveText (Join-Path $adm 'policy.adm') 'unrelated'}
+ }
+ Reject {Invoke-WelaGpoCreateCommand -ConfigPath $placeholderFixture.ConfigPath} '.'
+ }
+ $fixture=New-Fixture;$review=Ready $fixture
+ Assert ($review.Status -eq 'ReviewedInputsOnly' -and -not $review.DeploymentVerified -and $review.SigmaEvtxCredit -eq 0 -and $review.ReviewedSha256 -match '^[a-f0-9]{64}$') 'Review binds real component generation and all payload bytes without deployment claims'
+ Assert ($review.BackupId -ne $review.SourceGpoId) 'Backup-instance identity stays separate from source GPO GUID'
+ ResetNative;$planResult=Invoke-WelaGpoCreateCommand -Action Plan -ConfigPath $fixture.ConfigPath
+ Assert ($planResult.Status -eq 'PlanValidated' -and -not $script:created) 'Plan reads domain and refuses mutation'
+ ResetNative;$output=FreshPath;$dry=Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -Auto -DryRun -BackupPath $output
+ Assert ($dry.ExitCode -eq 0 -and $dry.Status -eq 'DryRun_NoGpoCreated' -and -not $script:created -and -not (Test-Path $output)) 'Public dry run creates no output or GPO'
+ ResetNative;function Read-Host {'n'};$output=FreshPath;$declined=Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -BackupPath $output
+ Assert ($declined.Status -eq 'Declined_NoGpoCreated' -and -not $script:created -and -not (Test-Path (Join-Path $output 'created-gpo.json'))) 'Decline preserves domain state'
+ ResetNative;$output=FreshPath;$success=Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -Auto -BackupPath $output
+ Assert ($success.ExitCode -eq 0 -and $success.Status -eq 'DisabledUnlinkedCandidateVerified' -and $script:created -eq 1 -and $script:imported -eq 1) 'One public command orchestrates create, disable, exact GUID import and final verification'
+ Assert ($success.CreatedGpoId -eq $script:createdId -and -not $success.DeploymentVerified -and $success.SigmaEvtxCredit -eq 0) 'A verified candidate never claims activated policy or detections'
+ foreach($name in @('reviewed-plan.json','before.jsonl','creation-intent.json','created-gpo.json','blank-target.json','import-intent.json','result.json')) {Assert (Test-Path (Join-Path $output $name)) "Durable evidence retained: $name"}
+ $receipt=Get-Content (Join-Path $output 'created-gpo.json') -Raw|ConvertFrom-Json;Assert ($receipt.Id -eq $script:createdId -and $receipt.Marker -ceq $script:marker) 'Created GUID and marker are recorded before import'
+ ResetNative;Reject {Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -Auto -BackupPath $output} 'fresh output'
+ ResetNative;$script:exists=$true;Reject {Invoke-WelaGpoCreateCommand -Action Plan -ConfigPath $fixture.ConfigPath} 'already exists';Assert (-not $script:created) 'Existing name is never reused'
+ ResetNative;$script:fault='domain';Reject {Invoke-WelaGpoCreateCommand -Action Plan -ConfigPath $fixture.ConfigPath} 'domain GUID';Assert (-not $script:created) 'Unknown/wrong domain fails before creation'
+ foreach($faultCase in @('domain-race','target-race','native-status','content','acl','linked','final-drift')) {
+ ResetNative;$script:fault=$faultCase;$output=FreshPath;$failed=Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -Auto -BackupPath $output
+ Assert ($failed.ExitCode -eq 1 -and $failed.Status -eq 'Failed_ReviewRetainedReceipts') "$faultCase is an explicit failed candidate, never a successful handoff"
+ if($faultCase -in @('domain-race','target-race')) {Assert ($script:imported -eq 0) "$faultCase blocks import"}
+ if($script:created){Assert (Test-Path (Join-Path $output 'created-gpo.json')) "$faultCase retains exact created identity"}
+ }
+ foreach($case in @('extra-file','extra-directory','audit-mask','user-row','security-right','security-type','report-mask','report-enabled','report-unknown','backup-enabled','backup-id','path-traversal','callback','unknown-extension','duplicate-row','xml-dtd','native-report')) {
+ $f=New-Fixture;$backupFile=Join-Path $f.Backup 'Backup.xml';$reportFile=Join-Path $f.Backup 'gpreport.xml';$auditFile=Join-Path $f.Backup 'DomainSysvol/GPO/Machine/microsoft/windows nt/Audit/audit.csv';$templateFile=Join-Path $f.Backup 'DomainSysvol/GPO/Machine/microsoft/windows nt/SecEdit/GptTmpl.inf'
+ switch($case) {
+ extra-file {SaveText (Join-Path $f.Backup 'evil.ps1') 'unrelated'}
+ extra-directory {$null=New-Item -ItemType Directory (Join-Path $f.Backup 'Unknown')}
+ audit-mask {SaveText $auditFile ([IO.File]::ReadAllText($auditFile).Replace(',3',',2'))}
+ user-row {SaveText $auditFile ([IO.File]::ReadAllText($auditFile).Replace(',System,',',User,'))}
+ security-right {Add-Content -LiteralPath $templateFile -Encoding Unicode -Value "`r`n[Privilege Rights]`r`nSeDebugPrivilege=*S-1-1-0"}
+ security-type {SaveText $templateFile ([IO.File]::ReadAllText($templateFile).Replace('=4,1','=1,1'))}
+ report-mask {SaveText $reportFile ($f.Report.Replace('3','2'))}
+ report-enabled {SaveText $reportFile ($f.Report.Replace('false','true'))}
+ report-unknown {SaveText $reportFile ($f.Report.Replace('','1'))}
+ backup-enabled {SaveText $backupFile ([IO.File]::ReadAllText($backupFile).Replace('3','0'))}
+ backup-id {SaveText (Join-Path $f.Backup 'bkupInfo.xml') ([IO.File]::ReadAllText((Join-Path $f.Backup 'bkupInfo.xml')).Replace($script:backupId,$script:sourceId))}
+ path-traversal {SaveText $backupFile ([IO.File]::ReadAllText($backupFile).Replace('bkp:Path="%GPO_MACH_FSPATH%','bkp:Path="../%GPO_MACH_FSPATH%'))}
+ callback {SaveText $backupFile ([IO.File]::ReadAllText($backupFile).Replace(']>'+$f.Report)}
+ native-report {function Get-WelaGpoNativeBackup {param($Root,$Id) [pscustomobject]@{Xml=$fixture.Report.Replace('Audit Source','Different Source');Backup='FixtureNativeBackup'}}}
+ }
+ ResetNative;Reject {Invoke-WelaGpoCreateCommand -ConfigPath $f.ConfigPath} '.';Assert (-not $script:created) "$case is rejected before domain creation"
+ ${function:Get-WelaGpoNativeBackup}=$script:nativeBackupAdapter
+ }
+ $f=New-Fixture;$null=Ready $f;$f.Config.ReviewedSha256='0'*64;SaveText $f.ConfigPath ($f.Config|ConvertTo-Json)
+ Reject {Invoke-WelaGpoCreateCommand -Action Plan -ConfigPath $f.ConfigPath} 'ReviewedSha256'
+ $f=New-Fixture;$original=$f.Config|ConvertTo-Json -Compress;$duplicate=$original.Replace('"SchemaVersion":1','"SchemaVersion":1,"SchemaVersion":1')
+ Assert ($duplicate -cne $original) 'Duplicate-key test actually changes JSON under both PowerShell engines'
+ SaveText $f.ConfigPath $duplicate
+ Reject {Read-WelaGpoCreateConfig $f.ConfigPath} 'Duplicate'
+ foreach($field in @('Domain','Dc')) {$f=New-Fixture;$f.Config[$field]='10.0.0.1';SaveText $f.ConfigPath ($f.Config|ConvertTo-Json);Reject {Read-WelaGpoCreateConfig $f.ConfigPath} 'DNS names'}
+ Reject {Assert-WelaGpmResult ([pscustomobject]@{OverallStatus=0})} 'native GPMC result'
+ Reject {Invoke-WelaGpoCreateCommand -Action Review -ConfigPath $fixture.ConfigPath -Auto} 'require'
+ Reject {Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath} 'explicit fresh'
+ # Durable receipt failure after creation blocks disabling/import; the precreation marker still exists.
+ $writer=${function:Write-WelaGpoReceipt}
+ function Write-WelaGpoReceipt {param($Root,$Name,$Value) if($Name -eq 'created-gpo.json'){throw 'Receipt disk failure'};& $writer $Root $Name $Value}
+ ResetNative;$output=FreshPath;$failed=Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -Auto -BackupPath $output
+ Assert ($failed.ExitCode -eq 1 -and $script:created -eq 1 -and $script:disabled -eq 0 -and $script:imported -eq 0 -and $failed.CreatedGpoId -eq $script:createdId) 'Postcreation receipt failure leaves an empty GPO and never imports policy'
+ ${function:Write-WelaGpoReceipt}=$writer
+ # Prompt-time input change is caught after consent and before any native mutation.
+ ResetNative;function Read-Host {Add-Content (Join-Path $fixture.Package 'audit.csv') 'drift';'y'};$output=FreshPath
+ $failed=Invoke-WelaGpoCreateCommand -Action Create -ConfigPath $fixture.ConfigPath -BackupPath $output
+ Assert ($failed.ExitCode -eq 1 -and -not $script:created) 'Changed package after prompt is refused before creation'
+ Write-Host "GPO creation fixtures: $script:checks assertions passed (native domain operations mocked)."
+} finally {Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue}
+$global:LASTEXITCODE=0
diff --git a/tests/GpoCreation.Windows.Tests.ps1 b/tests/GpoCreation.Windows.Tests.ps1
new file mode 100644
index 00000000..8bf7851f
--- /dev/null
+++ b/tests/GpoCreation.Windows.Tests.ps1
@@ -0,0 +1,72 @@
+# Genuine Microsoft backup read + native workgroup refusal; no domain creation/import is attempted.
+param([Parameter(Mandatory)][string]$OutputPath,[switch]$AllowHostedGpmcInstall)
+$ErrorActionPreference='Stop'
+if($env:OS -ne 'Windows_NT') {throw 'Windows is required.'}
+$repo=Split-Path $PSScriptRoot -Parent
+Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
+. (Join-Path $repo 'scripts/Configuration.ps1')
+. (Join-Path $repo 'scripts/GpoAuditPackages.ps1')
+. (Join-Path $repo 'scripts/EvtxRecovery.ps1')
+. (Join-Path $repo 'scripts/AdObjectSacl.ps1')
+. (Join-Path $repo 'scripts/GpoCreation.ps1')
+$script:checks=0
+function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
+function Reject([scriptblock]$Code,[string]$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
+function New-GPO {throw 'Forbidden domain mutation in read-only native test.'}
+function Import-WelaGpoNativeTarget {throw 'Forbidden domain import in read-only native test.'}
+$computer=Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
+Assert ($computer.PartOfDomain -eq $false) 'Native negative test requires an actual workgroup host'
+if($AllowHostedGpmcInstall) {
+ if($env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted') {throw 'GPMC installation is restricted to explicitly opted-in disposable GitHub-hosted runners.'}
+ Import-Module ServerManager -ErrorAction Stop
+ if((Get-WindowsFeature GPMC).Installed -ne $true) {
+ $installed=Install-WindowsFeature GPMC -ErrorAction Stop
+ if(-not $installed.Success -or [string]$installed.RestartNeeded -ne 'No') {throw 'GPMC installation requires successful completion without a pending restart; native evidence is blocked.'}
+ }
+}
+$null=New-WelaGpm
+$before=Get-WelaEffectiveAuditPolicy;$precedenceBefore=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
+$root=Resolve-WelaEvtxPath $OutputPath
+if(Test-Path $root){throw 'Use a fresh native artifact directory.'}
+$null=New-Item -ItemType Directory $root;Protect-WelaGpoOutput $root
+try {
+ $url='https://download.microsoft.com/download/8/5/c/85c25433-a1b0-4ffa-9429-7e023e7da8d8/Windows%20Server%202022%20Security%20Baseline.zip'
+ $expectedHash='49590cc694626d171fc934fafea6494f13ecd3843086704b7a5b98355909b8e0'
+ $zipPath=Join-Path $root 'sct.zip'
+ [Net.ServicePointManager]::SecurityProtocol=[Net.SecurityProtocolType]::Tls12
+ Invoke-WebRequest -Uri $url -OutFile $zipPath -UseBasicParsing -ErrorAction Stop
+ Assert ((Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $expectedHash) 'Official SCT archive matches the reviewed SHA-256 pin'
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
+ $extract=Join-Path $root 'sct';$null=New-Item -ItemType Directory $extract
+ $zip=[IO.Compression.ZipFile]::OpenRead($zipPath)
+ try {
+ foreach($entry in $zip.Entries) {
+ $destination=[IO.Path]::GetFullPath((Join-Path $extract $entry.FullName))
+ if(-not $destination.StartsWith($extract+[IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) {throw 'Archive traversal refused.'}
+ }
+ } finally {$zip.Dispose()}
+ [IO.Compression.ZipFile]::ExtractToDirectory($zipPath,$extract)
+ $backupRoot=Join-Path $extract 'Windows Server-2022-Security-Baseline-FINAL/GPOs'
+ $backupId='20fad6fb-7c6d-496e-801c-0434769847ff'
+ $native=Get-WelaGpoNativeBackup $backupRoot $backupId
+ $doc=Read-WelaGpoXml $native.Xml
+ $sourceId=Get-WelaGpoGuid (Get-WelaGpoText $doc.DocumentElement.Identifier 'Identifier' 'http://www.microsoft.com/GroupPolicy/Types')
+ Assert ($sourceId -eq 'fa0f36d8-14ce-4d94-90f7-66a01ddb07c4' -and $sourceId -ne $backupId) 'Native GPMC reads the genuine selected backup instance and distinct source GPO GUID'
+ Assert ((Get-WelaGpoText $doc.DocumentElement 'Name') -ceq 'MSFT Windows Server 2022 - Member Server') 'Native report retains official source identity'
+ [IO.File]::WriteAllText((Join-Path $root 'native-backup-report.xml'),$native.Xml,[Text.Encoding]::Unicode)
+ $package=Join-Path $root 'package';$null=Export-WelaGpoPackage (Get-WelaGpoPackagePlan wela-2.2.0 MemberServer 20348) $package
+ $config=[ordered]@{SchemaVersion=1;PackagePath=$package;BackupRoot=$backupRoot;BackupId=$backupId;Domain='example.test';DomainGuid='dddddddd-dddd-dddd-dddd-dddddddddddd';Dc='dc.example.test';Name='Read Only Native Refusal';ReviewedSha256=''}
+ $configPath=Join-Path $root 'create.json';[IO.File]::WriteAllText($configPath,($config|ConvertTo-Json),[Text.UTF8Encoding]::new($false))
+ Reject {Invoke-WelaGpoCreateCommand -ConfigPath $configPath} 'narrow audit payload'
+ Reject {Open-WelaGpoCreationSession ([pscustomobject]$config)} 'workgroup'
+ Reject {Get-WelaGpoNativeBackup $backupRoot '11111111-1111-1111-1111-111111111111'} '.'
+ # The source files and overall-status method are actual native evidence; nothing is imported.
+ Write-WelaGpoReceipt $root 'native-evidence.json' ([ordered]@{ArchiveUrl=$url;ArchiveSha256=$expectedHash;BackupId=$backupId;SourceGpoId=$sourceId;PowerShell=$PSVersionTable.PSVersion.ToString();NativeBackupReportObserved=$true;BroadPayloadRefused=$true;WorkgroupGuardObserved=$true;DomainCreationTested=$false;DomainImportTested=$false;DeploymentVerified=$false})
+} finally {
+ $after=Get-WelaEffectiveAuditPolicy;$precedenceAfter=Get-WelaRegistryState 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' SCENoApplyLegacyAuditPolicy
+ $beforeKey=@($before.Keys|Sort-Object|ForEach-Object {$_+'='+$before[$_]}) -join ';';$afterKey=@($after.Keys|Sort-Object|ForEach-Object {$_+'='+$after[$_]}) -join ';'
+ Assert ($beforeKey -ceq $afterKey) 'All actual native audit masks remain unchanged'
+ Assert (($precedenceBefore|ConvertTo-Json -Compress) -ceq ($precedenceAfter|ConvertTo-Json -Compress)) 'Actual precedence state remains unchanged'
+}
+Write-Host "GPO creation native checks: $script:checks passed. Genuine GPMC backup read and negative workgroup/broad-policy checks only; positive domain import remains pending."
+$global:LASTEXITCODE=0
diff --git a/website/docs/resources/changelog.ja.md b/website/docs/resources/changelog.ja.md
index dee1d640..da547466 100644
--- a/website/docs/resources/changelog.ja.md
+++ b/website/docs/resources/changelog.ja.md
@@ -7,6 +7,7 @@
**改善:**
+- 正規バックアップと現在の WELA 監査コンポーネントを照合し、新規・無効・未リンクの GPO のみを作成する `gpo-create` の Review / Plan / Create を追加しました。実ファイルとネイティブレポートの厳密な検証、明示的なドメイン/書き込み可能 DC、変更しない保護付きバックアップコピー、永続 GUID 記録、内容・無効状態・権限・リンク・バージョンの直前/最終確認で既存ポリシーを保護します。Windows テストは Microsoft の固定バックアップの読み取りと対象外ポリシー/ワークグループの拒否を確認し、実 AD/SYSVOL への正常インポートとクライアント/イベントの受け入れ検証は別途必要です。適用や Sigma の有効性は主張しません。(#427) (@Shirofune-Security)
- 既に無効なネイティブ購読のクエリと説明だけを変更する `wec-update` を追加。定義・実ホスト・コードの指紋、レビュー済み計画のハッシュ、永続レシート、直前確認と変更後の読み戻しにより、再作成や有効化をせずに変更を検証します。使い捨て Windows テストは実更新・復元と古い計画の拒否を確認します。稼働中ソースのブックマーク・配送・Sigma 準備状態は未検証です。 (#426) (@Shirofune-Security)
- 任意実行の`dns-analytical`を追加し、DNS Serverの分析ログを監査・計画・明示選択で設定できるようにしました。トレース再設定への個別同意、永続的な変更前記録、容量を制限したネイティブETLの退避とハッシュ検証に対応し、ACL・パス・既存の大きいバッファを保持します。退避失敗で停止した状態を失敗として報告します。使い捨てDNS環境のループバックイベント257と設定復元のテストを追加し、転送・Sigmaの利用可能性は未検証のままです。 (#425) (@Shirofune-Security)
- 読み取り専用の`wec-runtime`を追加し、WEC標準APIの稼働状態、数値エラー、UTC時刻と件数を制限した送信元別の観測結果を取得します。実行者・ホスト・定義の変更、不明な値と上限到達を明示し、既存のWEF・保存状態レポートでは元の文字列も保持します。過去の送信元一覧を現在の接続数とは扱わず、Activeからイベント到着やSigma利用可能性を推定しません。無効な使い捨てサブスクリプションで検証し、サービス状態とテスト対象を復元します。WEF・EVTXの合成テスト資料で時刻の末尾ゼロが失われる問題も修正しました。 (#424) (@Shirofune-Security)
diff --git a/website/docs/resources/changelog.md b/website/docs/resources/changelog.md
index 3fd49756..f0f03a4a 100644
--- a/website/docs/resources/changelog.md
+++ b/website/docs/resources/changelog.md
@@ -7,6 +7,7 @@
**Improvements:**
+- Added opt-in `gpo-create` review, plan and new disabled/unlinked GPO creation from an exact genuine backup matched to current WELA audit components. Strict payload/native-report validation, explicit domain/writable-DC identity, protected unchanged backup copies, durable GUID receipts and fresh/final content, flags, permissions, link and version checks preserve existing policies. Native Windows tests read a pinned Microsoft backup and exercise broad-payload/workgroup refusal; positive AD/SYSVOL import and client/event acceptance remain pending, with no deployment or Sigma credit. (#427) (@Shirofune-Security)
- Added `wec-update` to review and apply query/description changes to one already disabled native subscription through existing-only WEC handles. Complete definition/context/code fingerprints, a separately reviewed plan hash, durable receipts, fresh checks and preserved-property readback reject drift without recreation or activation. Disposable Windows tests cover actual updates/restoration and stale plans; active-source bookmarks, delivery and Sigma readiness remain unverified. (#426) (@Shirofune-Security)
- Added opt-in `dns-analytical` auditing, planning and selective DNS Server channel configuration with explicit trace-reset consent, durable state records and bounded native ETL archives verified before resets. Preserve ACLs, paths and larger buffers; report stopped partial failures honestly. Added disposable standalone-DNS tests for loopback event 257 and exact configuration restoration; forwarding and Sigma readiness remain unverified. (#425) (@Shirofune-Security)
- Added read-only `wec-runtime` with typed native WEC activity, numeric errors, UTC timestamps and bounded per-source observations. Actual reader/context and definition checks keep partial reads, caps and drift explicit; existing WEF/retention inventories retain raw text alongside typed fields. Historical source lists are not connection counts and Active grants no arrival or Sigma credit. Disposable disabled-subscription tests restore service state and remove only their owned fixture. Also fixed synthetic WEF/EVTX fixture timestamp roundtrips without weakening bundle validation. (#424) (@Shirofune-Security)