Files
田中ザック Isaac Mathis f1ed90d189 Create new disabled, unlinked GPOs from reviewed native audit backups (#427)
* Add guarded creation of disabled unlinked audit GPOs

* Reference PR 427 in GPO creation changelogs

* Accept only inert native ADM placeholders and fix PS5 JSON fixture

* Preserve fractional UTC strings in existing probe fixtures
2026-09-20 22:53:00 +09:00

496 lines
45 KiB
PowerShell

# 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
}