mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-09 15:55:25 +02:00
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
This commit is contained in:
1 parent
ec6a6df68a
commit
3a80ef5e67
12 files changed
+570
-2
No files matched your search
@@ -0,0 +1,126 @@
|
||||
# Offline fixtures only; no LGPO, auditpol mutation, GPMC or domain operations.
|
||||
$ErrorActionPreference='Stop'
|
||||
$repo=Split-Path $PSScriptRoot -Parent
|
||||
Import-Module (Join-Path $repo 'modules/AuditProfiles.psm1') -Force
|
||||
. (Join-Path $repo 'scripts/GpoAuditPackages.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'}
|
||||
function Import-GPO {throw 'Forbidden domain mutation'}
|
||||
function Set-WelaEffectiveAuditPolicy {throw 'Forbidden policy mutation'}
|
||||
function Invoke-WelaNative {throw 'Forbidden native process'}
|
||||
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-gpo-tests-'+[guid]::NewGuid().ToString('N'))
|
||||
$null=New-Item -ItemType Directory -Path $temp
|
||||
function FreshPath {Join-Path $temp ([guid]::NewGuid().ToString('N'))}
|
||||
function Clone($Value){ConvertTo-Json -InputObject $Value -Depth 18|ConvertFrom-Json}
|
||||
function SaveManifest($Path,$Manifest){$Manifest|ConvertTo-Json -Depth 18|Set-Content -LiteralPath (Join-Path $Path 'manifest.json') -Encoding UTF8}
|
||||
try {
|
||||
$plan=Get-WelaGpoPackagePlan -Profile wela-2.2.0 -Role Client -Build 26100
|
||||
Assert ($plan.Controls.Count -eq 59 -and $plan.Blockers.Count -eq 0) 'Shared catalog produces the full review, including omitted controls'
|
||||
Assert ($plan.ContextBasis -match 'Operator-declared' -and $plan.SigmaEvtxCredit -eq 0 -and -not $plan.ImportableGpoBackup -and -not $plan.DeploymentVerified) 'Declared deployment context is never host, GPO or detection evidence'
|
||||
Assert ($plan.ProfileSchemaSha256 -match '^[a-f0-9]{64}$' -and $plan.Sources.Count -gt 0 -and $plan.UnsupportedControls.Count -gt 0) 'Source fingerprint, provenance and unsupported scopes stay visible'
|
||||
Assert (@($plan.Controls|Where-Object {$_.Name -eq 'Directory Service Changes' -and $_.Disposition -eq 'Omitted'}).Count -eq 1) 'DC-only directory auditing is omitted on a client'
|
||||
$dc=Get-WelaGpoPackagePlan -Profile wela-2.2.0 -Role DomainController -Build 20348
|
||||
Assert (@($dc.Controls|Where-Object {$_.Name -eq 'Directory Service Changes' -and $_.Disposition -eq 'Exported'}).Count -eq 1) 'The same source respects explicit DC scope'
|
||||
$ca=Get-WelaGpoPackagePlan -Profile wela-2.2.0 -Role ADCS -Build 20348
|
||||
Assert (@($ca.Controls|Where-Object {$_.Name -eq 'Certification Services' -and $_.Disposition -eq 'Exported'}).Count -eq 1) 'AD CS target audit policy is retained without claiming AuditFilter or SACL configuration'
|
||||
Reject {Get-WelaGpoPackagePlan -Profile cis-server2022-v4-l1 -Role Client -Build 26100} 'does not support'
|
||||
Reject {Get-WelaGpoPackagePlan -Profile unknown -Role Client -Build 26100} 'Unknown audit profile'
|
||||
$reference=Get-WelaGpoPackagePlan -Profile windows-defaults-reviewed-2026-09 -Role Client -Build 26100
|
||||
Assert ($reference.Blockers -match 'Reference-only') 'Documentary defaults cannot become deployment policy'
|
||||
$minimum=Get-WelaGpoPackagePlan -Profile microsoft-wef-reviewed-2026-09 -Role MemberServer -Build 20348
|
||||
Assert ($minimum.Blockers.Count -gt 0 -and @($minimum.Controls|Where-Object {$_.SourceMode -eq 'minimum' -and $_.RequiredMask -in @(1,2) -and $_.Disposition -eq 'Exported'}).Count -eq 0) 'Minimum masks cannot silently become restrictive exact masks'
|
||||
$both=Get-WelaGpoPackagePlan -Profile microsoft-wef-reviewed-2026-09 -Role MemberServer -Build 20348 -MinimumMode PromoteToBoth
|
||||
Assert ($both.Blockers.Count -eq 0 -and @($both.Controls|Where-Object {$_.SourceMode -eq 'minimum' -and $_.RequiredMask -in @(1,2) -and $_.ExportMask -eq 3 -and $_.Reason -match 'expansion'}).Count -gt 0) 'Explicit promotion records each expanded exact Both mask'
|
||||
# Exercise all semantic states independently of which happen to exist in today's sources.
|
||||
$raw=Get-WelaAuditProfilePlan -Profile wela-2.2.0 -Role Client -Build 26100
|
||||
$fixture=Clone $raw;$base=$fixture.policies[0];$fixture.policies=@()
|
||||
foreach($setting in @(@('exact',1),@('exact',2),@('exact',3),@('minimum',0),@('minimum',1),@('minimum',2),@('minimum',3),@('not-configured',$null),@('unchanged',$null),@('not-applicable',$null),@('optional',3))) {
|
||||
$row=Clone $base;$row.mode=$setting[0];$row.requiredMask=$setting[1];$fixture.policies+=@($row)
|
||||
}
|
||||
$translated=ConvertTo-WelaGpoPackagePlan $fixture -MinimumMode PromoteToBoth
|
||||
Assert (($translated.Controls[0..2].ExportMask -join ',') -eq '1,2,3') 'Exact positive masks preserve their precise meaning'
|
||||
Assert ($translated.Controls[3].Disposition -eq 'Omitted' -and ($translated.Controls[4..6].ExportMask -join ',') -eq '3,3,3') 'Minimum zero omits and positive minima resolve only to Both'
|
||||
Assert (@($translated.Controls[7..10]|Where-Object Disposition -ne 'Omitted').Count -eq 0) 'Not Configured, unchanged, inapplicable and unselected optional rows remain omitted'
|
||||
$fixture.includeOptional=$true
|
||||
Assert ((ConvertTo-WelaGpoPackagePlan $fixture -MinimumMode PromoteToBoth).Controls[10].ExportMask -eq 3) 'Selected optional positive mask becomes exact'
|
||||
$fixture.policies[0].requiredMask=0
|
||||
$zero=ConvertTo-WelaGpoPackagePlan $fixture -MinimumMode PromoteToBoth
|
||||
Assert ($zero.Controls[0].Disposition -eq 'Blocked' -and $zero.Controls[0].Reason -match 'value 4') 'No Auditing versus unchanged CSV conflict is explicit and blocks export'
|
||||
$zeroPath=FreshPath;Reject {Export-WelaGpoPackage $zero $zeroPath} 'blocked';Assert (-not(Test-Path $zeroPath)) 'Blocked plans create no output'
|
||||
$fixture.policies=@($fixture.policies[7]);$empty=ConvertTo-WelaGpoPackagePlan $fixture
|
||||
Assert ($empty.Blockers -match 'No applicable') 'An omission-only profile cannot silently export precedence alone'
|
||||
$plain=Get-WelaGpoComponentContent $plan
|
||||
$csv=@($plain['audit.csv'].Text|ConvertFrom-Csv)
|
||||
Assert ($csv.Count -eq @($plan.Controls|Where-Object Disposition -eq 'Exported').Count) 'CSV contains exactly the selected rows'
|
||||
Assert (@($csv|Where-Object {$_.'Policy Target' -cne 'System' -or $_.'Machine Name' -ne '' -or $_.'Exclusion Setting' -ne '' -or $_.'Setting Value' -notin @('1','2','3')}).Count -eq 0) 'CSV has no per-user/exclusion/audit-option or zero rows'
|
||||
Assert ($csv[0].PSObject.Properties.Name.Count -eq 7 -and $plain['audit.csv'].Text -notmatch '(?<!\r)\n') 'Documented CSV header has seven columns and CRLF endings'
|
||||
$rpc=@($csv|Where-Object Subcategory -eq 'RPC Events')
|
||||
$token=@($plan.Controls|Where-Object Name -eq 'Token Right Adjusted Events')
|
||||
Assert ($rpc.Count -eq 1 -and $rpc[0].'Subcategory GUID' -eq '{0CCE922E-69AE-11D9-BED3-505054503030}' -and $token[0].Guid -eq '0CCE924A-69AE-11D9-BED3-505054503030' -and $token[0].Disposition -eq 'Omitted') 'RPC exports independently while unchanged token auditing retains its canonical review identity'
|
||||
$regLines=@($plain['GptTmpl.inf'].Text -split "`r`n"|Where-Object {$_ -like 'MACHINE*'})
|
||||
Assert ($regLines.Count -eq 1 -and $regLines[0] -ceq 'MACHINE\System\CurrentControlSet\Control\Lsa\SCENoApplyLegacyAuditPolicy=4,1') 'Template contains only the typed precedence value'
|
||||
$infBytes=Get-WelaGpoContentBytes $plain['GptTmpl.inf'];$csvBytes=Get-WelaGpoContentBytes $plain['audit.csv']
|
||||
Assert ($infBytes[0] -eq 255 -and $infBytes[1] -eq 254 -and $csvBytes[0] -eq [byte][char]'M') 'Security template has UTF-16LE BOM; audit CSV is UTF-8 without BOM'
|
||||
$path=FreshPath;$dry=Export-WelaGpoPackage $plan $path -DryRun
|
||||
Assert ($dry.DryRun -and -not(Test-Path $path) -and @(Get-ChildItem $temp -Force).Count -eq 0) 'Dry-run creates neither output nor staging directory'
|
||||
$result=Export-WelaGpoPackage $plan $path
|
||||
Assert ($result.ExitCode -eq 0 -and $result.Action -eq 'Export' -and -not $result.DeploymentVerified -and @(Get-ChildItem $path -Force).Count -eq 5) 'Export publishes exactly the verified component files without deployment claims'
|
||||
$verified=Invoke-WelaGpoPackageCommand -Action Verify -Path $path
|
||||
Assert ($verified.ExitCode -eq 0 -and $verified.Action -eq 'Verify' -and $verified.Plan.Profile -eq $plan.Profile) 'Read-only verification reconstructs source intent'
|
||||
Reject {Export-WelaGpoPackage $plan $path} 'fresh output'
|
||||
$filePath=FreshPath;'keep'|Set-Content $filePath;Reject {Export-WelaGpoPackage $plan $filePath} 'fresh output';Assert ((Get-Content $filePath) -eq 'keep') 'Existing files are not overwritten'
|
||||
Reject {Export-WelaGpoPackage $plan (Join-Path (FreshPath) 'missing-parent')} 'parent directory'
|
||||
foreach($case in @('payload','updated-hash','intent','source-hash','extra-file','extra-manifest','extra-filemetadata','duplicate-file','missing-file','encoding','guide')) {
|
||||
$altered=FreshPath;$null=Copy-Item -LiteralPath $path -Destination $altered -Recurse
|
||||
$manifest=Get-Content (Join-Path $altered 'manifest.json') -Raw|ConvertFrom-Json
|
||||
switch($case) {
|
||||
'payload' {Add-Content (Join-Path $altered 'audit.csv') 'unreviewed'}
|
||||
'updated-hash' {Add-Content (Join-Path $altered 'audit.csv') 'unreviewed';$bytes=[IO.File]::ReadAllBytes((Join-Path $altered 'audit.csv'));$manifest.Files[0].Length=$bytes.Length;$manifest.Files[0].Sha256=Get-WelaGpoBytesHash $bytes}
|
||||
'intent' {$manifest.Plan.Controls[0].ExportMask=2}
|
||||
'source-hash' {$manifest.Plan.ProfileSchemaSha256='0'*64}
|
||||
'extra-file' {'unreviewed'|Set-Content (Join-Path $altered 'Backup.xml')}
|
||||
'extra-manifest' {$manifest|Add-Member NoteProperty ImportableGpoBackup $true}
|
||||
'extra-filemetadata' {$manifest.Files[0]|Add-Member NoteProperty Executable $true}
|
||||
'duplicate-file' {$manifest.Files[1]=$manifest.Files[0]}
|
||||
'missing-file' {Remove-Item (Join-Path $altered 'audit.csv')}
|
||||
'encoding' {$manifest.Files[0].Encoding='utf-16'}
|
||||
'guide' {Add-Content (Join-Path $altered 'deployment.md') 'unreviewed deployment'}
|
||||
}
|
||||
SaveManifest $altered $manifest
|
||||
Reject {Test-WelaGpoPackage $altered} 'differs|match|exactly|Unexpected|duplicate'
|
||||
}
|
||||
$link=FreshPath
|
||||
$null=New-Item -ItemType SymbolicLink -Path $link -Target $path
|
||||
Reject {Test-WelaGpoPackage $link} 'Reparse'
|
||||
Reject {Export-WelaGpoPackage $plan (Join-Path $link 'child')} 'Reparse'
|
||||
$memberLink=FreshPath;$null=Copy-Item $path $memberLink -Recurse
|
||||
Remove-Item (Join-Path $memberLink 'audit.csv');$null=New-Item -ItemType SymbolicLink -Path (Join-Path $memberLink 'audit.csv') -Target (Join-Path $path 'audit.csv')
|
||||
Reject {Test-WelaGpoPackage $memberLink} 'reparse'
|
||||
# A raced destination must survive; the staged payload must never merge into it.
|
||||
$racedPath=FreshPath;$originalVerifier=${function:Test-WelaGpoPackage}
|
||||
& {
|
||||
function Test-WelaGpoPackage {param($Path) if($Path -like '*stage*' -and -not(Test-Path $racedPath)){'concurrent owner'|Set-Content $racedPath};& $originalVerifier $Path}
|
||||
Reject {Export-WelaGpoPackage $plan $racedPath} 'did not complete'
|
||||
}
|
||||
Assert ((Get-Content $racedPath) -eq 'concurrent owner') 'Atomic publication preserves a concurrent destination'
|
||||
& {
|
||||
$script:hashReads=0
|
||||
function Get-FileHash {param($LiteralPath,$Algorithm,$ErrorAction) $script:hashReads++;if($script:hashReads -ge 2){[pscustomobject]@{Hash=('0'*64)}}else{Microsoft.PowerShell.Utility\Get-FileHash -LiteralPath $LiteralPath -Algorithm $Algorithm}}
|
||||
Reject {Get-WelaGpoPackagePlan -Profile wela-2.2.0 -Role Client -Build 26100} 'changed during planning'
|
||||
}
|
||||
Reject {Invoke-WelaGpoPackageCommand} 'explicit'
|
||||
Reject {Invoke-WelaGpoPackageCommand -Action Verify -Path $path -Profile wela-2.2.0} 'overrides'
|
||||
Reject {Invoke-WelaGpoPackageCommand -Action Verify -Path $path -DryRun} 'DryRun'
|
||||
Reject {Invoke-WelaGpoPackageCommand -Profile wela-2.2.0 -Role Client -Build 26100 -Path $path} 'only with Export'
|
||||
# The actual CLI's early guard runs before an unrelated configuration profile dispatch.
|
||||
$errors=$null;$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'WELA.ps1'),[ref]$null,[ref]$errors)
|
||||
Assert ($errors.Count -eq 0) 'Public CLI parses'
|
||||
$nodes=@($ast.EndBlock.Statements|Where-Object {$_ -is [Management.Automation.Language.IfStatementAst] -and ($_.Extent.Text -match 'GPO package options require' -or $_.Extent.Text -match 'Invoke-WelaProfileCommand -Command')})
|
||||
Assert ($nodes.Count -eq 2) 'Both dedicated guard and existing profile dispatcher remain present'
|
||||
$dispatch=[scriptblock]::Create('param($Cmd,$Profile,$GpoAction,$GpoProfile,$GpoOutputPath,$GpoMinimumMode)'+[Environment]::NewLine+(($nodes|ForEach-Object {$_.Extent.Text})-join [Environment]::NewLine))
|
||||
function Invoke-WelaProfileCommand {throw 'UNSAFE unrelated dispatcher'}
|
||||
foreach($option in @('GpoAction','GpoProfile','GpoOutputPath','GpoMinimumMode')) {$arguments=@{Cmd='configure';Profile='wela'};$arguments[$option]='value';Reject {& $dispatch @arguments} 'GPO package options require'}
|
||||
Write-Host "PASS: $script:checks GPO component assertions. No native policy or domain changes."
|
||||
} finally {Remove-Item -LiteralPath $temp -Recurse -Force -ErrorAction SilentlyContinue}
|
||||
@@ -0,0 +1,43 @@
|
||||
# Windows-only read-only native observations. The output directory is an owned test artifact.
|
||||
param([Parameter(Mandatory)][string]$OutputPath,[string]$VerifyOtherPath)
|
||||
$ErrorActionPreference='Stop'
|
||||
if ($env:OS -ne 'Windows_NT') {throw 'This smoke test requires Windows.'}
|
||||
$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')
|
||||
function New-GPO {throw 'Domain creation is forbidden in this read-only test.'}
|
||||
function Import-GPO {throw 'Domain import is forbidden in this read-only test.'}
|
||||
function Set-WelaEffectiveAuditPolicy {throw 'Native audit mutation is forbidden in this read-only test.'}
|
||||
function Set-ItemProperty {throw 'Registry mutation is forbidden in this read-only test.'}
|
||||
$script:checks=0
|
||||
function Assert($Value,[string]$Message){if(-not $Value){throw "FAIL: $Message"};$script:checks++}
|
||||
function PolicyFingerprint($State) {(@($State.Keys|Sort-Object|ForEach-Object {$_+'='+$State[$_]})-join ';')}
|
||||
$before=Get-WelaEffectiveAuditPolicy
|
||||
$precedenceBefore=Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy
|
||||
Assert ($before.Count -eq 59) 'Native API reads the actual 59 subcategory masks'
|
||||
try {
|
||||
# Explicit target is a package input, regardless of the Server runner's actual role/build.
|
||||
$plan=Get-WelaGpoPackagePlan -Profile wela-2.2.0 -Role Client -Build 26100
|
||||
$export=Export-WelaGpoPackage -Plan $plan -Path $OutputPath
|
||||
Assert ($export.ExitCode -eq 0 -and $export.Plan.Role -eq 'Client' -and -not $export.DeploymentVerified) 'Package target remains declared without host/application claims'
|
||||
$null=Invoke-WelaNative -FilePath (Join-Path $env:SystemRoot 'System32/secedit.exe') -Arguments @('/validate',(Join-Path $OutputPath 'GptTmpl.inf'))
|
||||
Assert $true 'Native secedit validates security-template syntax only'
|
||||
$verified=Test-WelaGpoPackage -Path $OutputPath
|
||||
Assert ($verified.ExitCode -eq 0) 'Template validation leaves the generated package intact'
|
||||
if ($VerifyOtherPath) {
|
||||
$other=Test-WelaGpoPackage -Path $VerifyOtherPath
|
||||
Assert ($other.ExitCode -eq 0) 'Package created by the other PowerShell edition verifies against this generator'
|
||||
foreach ($name in @('audit.csv','GptTmpl.inf','review.md','deployment.md')) {
|
||||
$left=Get-FileHash -LiteralPath (Join-Path $OutputPath $name) -Algorithm SHA256
|
||||
$right=Get-FileHash -LiteralPath (Join-Path $VerifyOtherPath $name) -Algorithm SHA256
|
||||
Assert ($left.Hash -eq $right.Hash) "Both editions generate identical $name bytes"
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$after=Get-WelaEffectiveAuditPolicy
|
||||
$precedenceAfter=Get-WelaRegistryState -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name SCENoApplyLegacyAuditPolicy
|
||||
Assert ((PolicyFingerprint $before) -ceq (PolicyFingerprint $after)) 'All effective native audit masks remain unchanged'
|
||||
Assert ((ConvertTo-Json $precedenceBefore -Compress) -ceq (ConvertTo-Json $precedenceAfter -Compress)) 'Precedence registry presence/type/value remain unchanged'
|
||||
}
|
||||
Write-Host "PASS: $script:checks read-only GPO component checks. No local/domain policy application; retain package for cross-edition verification."
|
||||
Reference in new issue
Block a user