Files
WELA/scripts/GpoAuditPackages.ps1
田中ザック Isaac Mathis 3a80ef5e67 Add reviewable GPO audit-policy deployment packages (#415)
* Add reviewable GPO audit-policy deployment components

* Link GPO audit package changelog to PR 415

* Check GPO verification exit code from a real CLI process
2026-09-20 18:13:34 +09:00

194 lines
17 KiB
PowerShell

# Offline deployment components only. This script never calls GPMC, LGPO or Windows policy writers.
function ConvertTo-WelaGpoPackagePlan {
param($ProfilePlan,[ValidateSet('Reject','PromoteToBoth')][string]$MinimumMode='Reject')
$rows=@();$blockers=@()
if ($ProfilePlan.referenceOnly) { $blockers+='Reference-only Windows defaults cannot be exported as a deployment policy.' }
foreach ($policy in $ProfilePlan.policies) {
$mask=$null;$disposition='Omitted';$reason=$policy.mode
if ($policy.mode -eq 'optional' -and -not $ProfilePlan.includeOptional) { $reason='Optional control not selected.' }
elseif ($policy.mode -in @('exact','minimum','optional')) {
if ($policy.mode -eq 'minimum' -and $policy.requiredMask -eq 0) { $reason='Minimum zero imposes no requirement; preserve by omission.' }
elseif ($policy.mode -eq 'minimum' -and $policy.requiredMask -in @(1,2) -and $MinimumMode -eq 'Reject') {
$disposition='Blocked';$reason='GPO stores an exact mask. Explicit PromoteToBoth is required to avoid turning off an existing opposite audit bit.'
} elseif ($policy.requiredMask -eq 0) {
$disposition='Blocked';$reason='Exact No Auditing export is unsupported: normative MS-GPAC uses value 4 for None, but a Microsoft example uses 0. Native deployment semantics remain unvalidated.'
} else {
$disposition='Exported'
$mask=if ($policy.mode -eq 'minimum') {3} else {[int]$policy.requiredMask}
$reason=if ($policy.mode -eq 'minimum' -and $policy.requiredMask -ne 3) {'Explicit expansion: minimum Success or Failure becomes exact Success and Failure; additional event volume is possible.'}
elseif ($policy.mode -eq 'minimum') {'Minimum Both is equivalent to exact Both.'}
else {'Exact source mask; an opposite audit bit may be disabled when deployed. Target effective state is not assessed.'}
}
}
if ($disposition -eq 'Blocked') { $blockers+="$($policy.id): $reason" }
$rows+=[pscustomobject][ordered]@{
Name=$policy.id;Guid=$policy.guid.ToUpperInvariant();Category=$policy.category;SourceMode=$policy.mode;RequiredMask=$policy.requiredMask
Disposition=$disposition;ExportMask=$mask;Reason=$reason;SourceIds=@($policy.sourceIds);Prerequisites=@($policy.prerequisites);SourceNote=$policy.note
}
}
if (-not @($rows|Where-Object Disposition -eq 'Exported').Count) { $blockers+='No applicable system audit subcategories were selected for export.' }
[pscustomobject][ordered]@{
SchemaVersion=1;Kind='WelaGpoAuditDeploymentComponents';Scope='advanced-audit-policy-and-precedence-components-only'
Profile=$ProfilePlan.profile;ProfileVersion=$ProfilePlan.version;Role=$ProfilePlan.role;Build=$ProfilePlan.build
ContextBasis='Operator-declared target role/build; no host or domain observations.'
IncludeOptional=[bool]$ProfilePlan.includeOptional;MinimumMode=$MinimumMode;ProfileSchemaSha256=$ProfilePlan.schemaSha256.ToLowerInvariant()
Sources=@($ProfilePlan.provenance);Controls=$rows;Blockers=$blockers
Precedence=[pscustomobject][ordered]@{Path='MACHINE\System\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy';Type='REG_DWORD';Value=1;Source='https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-gpac/e8edc8e2-4b91-433f-b1a2-672d4647e12f'}
ImportableGpoBackup=$false;DeploymentVerified=$false;SigmaEvtxCredit=0
UnsupportedControls=@('Event channel enablement, size, retention, permissions and WEF/collector configuration','Object, directory, registry, AD and WMI SACLs; AD CS AuditFilter','PowerShell module/script-block/transcription and process-command-line policies','Audit privileges, CrashOnAuditFail, notifications and diagnostic controls','Firewall text logs, SMB auditing, AppLocker and other provider-specific settings','Domain GPO creation, import, linking, filtering, delegation and client refresh')
}
}
function Get-WelaGpoPackagePlan {
param([Parameter(Mandatory)][string]$Profile,[Parameter(Mandatory)][ValidateSet('Client','MemberServer','DomainController','ADCS')][string]$Role,
[Parameter(Mandatory)][ValidateRange(1,999999)][int]$Build,[ValidateSet('Reject','PromoteToBoth')][string]$MinimumMode='Reject',[switch]$IncludeOptional)
$path=Join-Path $PSScriptRoot '../config/audit_profiles.json'
$before=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
$profilePlan=Get-WelaAuditProfilePlan -Profile $Profile -Role $Role -Build $Build -IncludeOptional:$IncludeOptional
$after=(Get-FileHash -LiteralPath $path -Algorithm SHA256 -ErrorAction Stop).Hash
if ($before -ne $after -or $after -ne $profilePlan.schemaSha256) { throw 'Shared audit profile source changed during planning; retry with a consistent source.' }
ConvertTo-WelaGpoPackagePlan -ProfilePlan $profilePlan -MinimumMode $MinimumMode
}
function Get-WelaGpoComponentContent {
param($Plan)
if ($Plan.Blockers.Count) { throw ('Package export blocked: '+($Plan.Blockers -join ' ')) }
$csv=@('Machine Name,Policy Target,Subcategory,Subcategory GUID,Inclusion Setting,Exclusion Setting,Setting Value')
foreach ($row in $Plan.Controls) {
if ($row.Disposition -ne 'Exported') { continue }
# Built-in catalog labels are non-executable readable names; GUID/value govern policy.
$name=$row.Name
if ($name -match '[,"\r\n]' -or $row.ExportMask -notin @(1,2,3)) { throw 'Unsupported audit CSV label or exact mask.' }
$csv+=",System,$name,{$($row.Guid)},$(Format-WelaAuditMask $row.ExportMask),,$($row.ExportMask)"
}
$template=@('[Unicode]','Unicode=yes','[Version]','signature="$CHICAGO$"','Revision=1','[Registry Values]',($Plan.Precedence.Path+'=4,1'))
$review=@('# WELA GPO audit-policy deployment components','',
'**This component folder is not a GPO backup and cannot be passed to Import-GPO. No host or domain policy has been changed.**','',
"Profile: $($Plan.Profile) ($($Plan.ProfileVersion)); declared target: $($Plan.Role), build $($Plan.Build).",
"Minimum policy: $($Plan.MinimumMode); optional controls selected: $($Plan.IncludeOptional).",
"Shared profile SHA-256: $($Plan.ProfileSchemaSha256)",'',
'The security template specifies only SCENoApplyLegacyAuditPolicy=1 (DWORD). Review precedence and effective policy separately after deployment.',
'Omitted rows are absent from audit.csv. They do not clear settings supplied by another GPO, and they do not mean No Auditing.','',
'| Subcategory / GUID | Source mode / mask | Export disposition / mask | Reason and prerequisites |','| --- | --- | --- | --- |')
foreach ($row in $Plan.Controls) {
$reason=($row.Reason+' '+($row.Prerequisites -join '; ')+' '+$row.SourceNote).Replace('|','\|').Replace("`r",' ').Replace("`n",' ')
$review+="| $($row.Name) / $($row.Guid) | $($row.SourceMode) / $($row.RequiredMask) | $($row.Disposition) / $($row.ExportMask) | $reason |"
}
$review+=@('','## Unsupported scope','')+@($Plan.UnsupportedControls|ForEach-Object {'- '+$_})
$review+=@('','## Profile source provenance','')
foreach ($source in $Plan.Sources) { $review+="- $($source.id): $($source.source.title); $($source.source.version). $($source.source.url)" }
$review+=@('',
'Component validation does not prove GPO import, replication, client application, event generation or ingestion. Sigma EVTX credit remains zero. See deployment.md for preparation, staged review and recovery.')
[ordered]@{
'audit.csv'=[pscustomobject]@{Text=($csv -join "`r`n")+"`r`n";Encoding='utf-8'}
'GptTmpl.inf'=[pscustomobject]@{Text=($template -join "`r`n")+"`r`n";Encoding='utf-16le-bom'}
'review.md'=[pscustomobject]@{Text=($review -join "`r`n")+"`r`n";Encoding='utf-8'}
'deployment.md'=[pscustomobject]@{Text=(Get-Content -LiteralPath (Join-Path $PSScriptRoot '../docs/gpo-package-deployment.md') -Raw -Encoding UTF8 -ErrorAction Stop).Replace("`r`n","`n").Replace("`n","`r`n");Encoding='utf-8'}
}
}
function Get-WelaGpoContentBytes {
param($Component)
if ($Component.Encoding -eq 'utf-8') { $encoding=New-Object Text.UTF8Encoding($false) }
elseif ($Component.Encoding -eq 'utf-16le-bom') { $encoding=New-Object Text.UnicodeEncoding($false,$true) }
else { throw 'Unknown component encoding.' }
return ,([byte[]](@($encoding.GetPreamble())+@($encoding.GetBytes($Component.Text))))
}
function Get-WelaGpoBytesHash {
param([byte[]]$Bytes)
$sha=[Security.Cryptography.SHA256]::Create()
try { ([BitConverter]::ToString($sha.ComputeHash($Bytes))).Replace('-','').ToLowerInvariant() } finally {$sha.Dispose()}
}
function Resolve-WelaGpoPackagePath {
param([Parameter(Mandatory)][string]$Path)
$provider=$null;$drive=$null
$full=$ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path,[ref]$provider,[ref]$drive)
if ($provider.Name -ne 'FileSystem') { throw 'Package paths must use the filesystem.' }
$full=[IO.Path]::GetFullPath($full)
if ($full.StartsWith('\\?\') -or $full.StartsWith('\\.\')) {throw 'Device paths are unsupported.'}
# Reject observed symbolic links/junctions, including ancestors. No traversal through them.
$check=$full
while ($check) {
if (Test-Path -LiteralPath $check) {
$item=Get-Item -LiteralPath $check -Force -ErrorAction Stop
if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {throw 'Reparse-point package paths are unsupported.'}
}
$parent=[IO.Directory]::GetParent($check)
if (-not $parent) {break};$check=$parent.FullName
}
return $full
}
function Test-WelaGpoPackage {
param([Parameter(Mandatory)][string]$Path)
$full=Resolve-WelaGpoPackagePath $Path
if (-not (Test-Path -LiteralPath $full -PathType Container)) {throw 'Package directory does not exist.'}
$names=@('audit.csv','GptTmpl.inf','review.md','deployment.md','manifest.json')
$files=@(Get-ChildItem -LiteralPath $full -Force -ErrorAction Stop)
if ($files.Count -ne $names.Count -or @($files|Where-Object {$_.PSIsContainer -or $_.Name -cnotin $names -or ($_.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0}).Count) {throw 'Package must contain exactly the five expected regular files, with no reparse points.'}
foreach ($file in $files) {if ($file.Length -gt 4194304) {throw 'Component exceeds the supported size limit.'}}
$manifest=Get-Content -LiteralPath (Join-Path $full 'manifest.json') -Raw -Encoding UTF8 -ErrorAction Stop|ConvertFrom-Json -ErrorAction Stop
if (@($manifest.PSObject.Properties.Name).Count -ne 5 -or @($manifest.PSObject.Properties.Name|Where-Object {$_ -cnotin @('SchemaVersion','Kind','CreatedUtc','Plan','Files')}).Count) {throw 'Unexpected manifest metadata.'}
$created=[DateTimeOffset]::MinValue
if (-not [DateTimeOffset]::TryParse([string]$manifest.CreatedUtc,[ref]$created)) {throw 'Invalid manifest creation timestamp.'}
if ($manifest.SchemaVersion -ne 1 -or $manifest.Kind -cne 'WelaGpoAuditDeploymentComponents' -or $manifest.Plan.IncludeOptional -isnot [bool] -or $manifest.Files.Count -ne 4) {throw 'Invalid deployment component manifest.'}
$expected=Get-WelaGpoPackagePlan -Profile $manifest.Plan.Profile -Role $manifest.Plan.Role -Build $manifest.Plan.Build -MinimumMode $manifest.Plan.MinimumMode -IncludeOptional:$manifest.Plan.IncludeOptional
if ((ConvertTo-Json -InputObject $manifest.Plan -Depth 18 -Compress) -cne (ConvertTo-Json -InputObject $expected -Depth 18 -Compress)) {throw 'Manifest intent/provenance does not match the installed shared profile. Regenerate and review the package.'}
$content=Get-WelaGpoComponentContent $expected
$seen=@{}
foreach ($entry in $manifest.Files) {
if (@($entry.PSObject.Properties.Name).Count -ne 4 -or @($entry.PSObject.Properties.Name|Where-Object {$_ -cnotin @('Name','Encoding','Length','Sha256')}).Count) {throw 'Unexpected component metadata.'}
if ($entry.Name -cnotin @($content.Keys) -or $seen.ContainsKey($entry.Name)) {throw 'Unexpected or duplicate manifest component.'}
$seen[$entry.Name]=$true
$bytes=[IO.File]::ReadAllBytes((Join-Path $full $entry.Name))
$hash=Get-WelaGpoBytesHash $bytes
$expectedHash=Get-WelaGpoBytesHash (Get-WelaGpoContentBytes $content[$entry.Name])
if ($entry.Encoding -cne $content[$entry.Name].Encoding -or $entry.Sha256 -cne $hash -or $entry.Length -ne $bytes.Length -or $hash -cne $expectedHash) {throw "Component content/hash differs from the reviewed installed profile: $($entry.Name)"}
}
[pscustomobject]@{Scope=$expected.Scope;Action='Verify';ExitCode=0;Path=$full;ComponentValidation='Matches installed shared profile and component generator';ImportableGpoBackup=$false;DeploymentVerified=$false;SigmaEvtxCredit=0;Plan=$expected}
}
function Export-WelaGpoPackage {
param($Plan,[Parameter(Mandatory)][string]$Path,[switch]$DryRun)
if ($Plan.Blockers.Count) {throw ('Package export blocked: '+($Plan.Blockers -join ' '))}
$full=Resolve-WelaGpoPackagePath $Path
if (Test-Path -LiteralPath $full) {throw 'Export requires a fresh output directory; existing files and directories are never reused.'}
$parent=[IO.Directory]::GetParent($full)
if (-not $parent -or -not (Test-Path -LiteralPath $parent.FullName -PathType Container)) {throw 'The output parent directory must already exist.'}
$content=Get-WelaGpoComponentContent $Plan
if ($DryRun) {return [pscustomobject]@{Scope=$Plan.Scope;Action='Export';ExitCode=0;DryRun=$true;Path=$full;ComponentValidation='Not exported';ImportableGpoBackup=$false;DeploymentVerified=$false;SigmaEvtxCredit=0;Plan=$Plan}}
$stage=Join-Path $parent.FullName ('.wela-gpo-stage-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $stage -ErrorAction Stop
try {
$entries=@()
foreach ($name in $content.Keys) {
$bytes=Get-WelaGpoContentBytes $content[$name]
$stream=[IO.File]::Open((Join-Path $stage $name),[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None)
try {$stream.Write($bytes,0,$bytes.Length);$stream.Flush()} finally {$stream.Dispose()}
$entries+=[pscustomobject][ordered]@{Name=$name;Encoding=$content[$name].Encoding;Length=$bytes.Length;Sha256=(Get-WelaGpoBytesHash $bytes)}
}
$manifest=[pscustomobject][ordered]@{SchemaVersion=1;Kind='WelaGpoAuditDeploymentComponents';CreatedUtc=[DateTime]::UtcNow.ToString('o');Plan=$Plan;Files=$entries}
[IO.File]::WriteAllText((Join-Path $stage 'manifest.json'),(ConvertTo-Json -InputObject $manifest -Depth 18),(New-Object Text.UTF8Encoding($false)))
$null=Test-WelaGpoPackage $stage
$null=Resolve-WelaGpoPackagePath $full
# Directory.Move refuses a raced destination; no overwrite or recursive merge.
[IO.Directory]::Move($stage,$full)
$result=Test-WelaGpoPackage $full
$result.Action='Export';$result|Add-Member NoteProperty DryRun $false
return $result
} catch {throw "Component export did not complete: $($_.Exception.Message) Review any retained staging directory '$stage' or output '$full'; no Windows policy was changed."}
}
function Invoke-WelaGpoPackageCommand {
param([ValidateSet('Plan','Export','Verify')][string]$Action='Plan',[string]$Profile,[string]$Role,[int]$Build,
[ValidateSet('Reject','PromoteToBoth')][string]$MinimumMode='Reject',[switch]$IncludeOptional,[string]$Path,[switch]$DryRun)
if ($DryRun -and $Action -ne 'Export') {throw '-DryRun requires GpoAction Export; Plan and Verify are read-only.'}
if ($Action -eq 'Verify') {
if ($Profile -or $Role -or $Build -or $IncludeOptional -or $MinimumMode -ne 'Reject') {throw 'Verify reads the package context; profile, role, build and expansion overrides are unsupported.'}
if (-not $Path) {throw 'Verify requires -GpoOutputPath.'}
return Test-WelaGpoPackage $Path
}
if (-not $Profile -or -not $Role -or -not $Build) {throw 'Plan and Export require explicit -GpoProfile, -Role and -Build for the declared deployment target.'}
$plan=Get-WelaGpoPackagePlan -Profile $Profile -Role $Role -Build $Build -MinimumMode $MinimumMode -IncludeOptional:$IncludeOptional
if ($Action -eq 'Export') {
if (-not $Path) {throw 'Export requires -GpoOutputPath.'}
return Export-WelaGpoPackage -Plan $plan -Path $Path -DryRun:$DryRun
}
if ($Path) {throw 'GpoOutputPath is supported only with Export or Verify.'}
[pscustomobject]@{Scope=$plan.Scope;Action='Plan';ExitCode=$(if ($plan.Blockers.Count){1}else{0});ImportableGpoBackup=$false;DeploymentVerified=$false;SigmaEvtxCredit=0;Plan=$plan}
}