Files
WELA/tests/CustomAuditProfiles.Tests.ps1
田中ザック Isaac Mathis 83b2ddd526 Support validated custom audit profile files through the shared engine (#416)
* Support validated operator-owned advanced audit profile files

* Reject lenient custom profile JSON and protect report output aliases

* Link custom audit profile changelog to PR 416

* Make custom JSON rejection fixtures portable across PowerShell versions
2026-09-20 18:09:52 +09:00

189 lines
16 KiB
PowerShell

$ErrorActionPreference='Stop'
$root=Split-Path $PSScriptRoot -Parent
$script:ScriptRoot=$root
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
. (Join-Path $root 'scripts/Configuration.ps1')
$script:count=0
function Assert($Condition,$Message) { if (-not $Condition) { throw $Message }; $script:count++ }
function Throws($Action,$Pattern) { $message=''; try { & $Action | Out-Null } catch { $message=$_.Exception.Message }; Assert ($message -match $Pattern) "Expected $Pattern; got $message" }
function Copy-Fixture($Object) { $Object | ConvertTo-Json -Depth 20 | ConvertFrom-Json }
$sample=Get-Content -LiteralPath (Join-Path $root 'config/custom-audit-profile.example.json') -Raw | ConvertFrom-Json
$temp=Join-Path ([IO.Path]::GetTempPath()) ('wela-custom-'+[guid]::NewGuid().ToString('N'))
$null=New-Item -ItemType Directory -Path $temp
$script:file=Join-Path $temp 'profile.json'
function Save($Value=$sample) { $Value | ConvertTo-Json -Depth 20 | Set-Content -LiteralPath $script:file -Encoding UTF8 }
function Bad($Edit,$Pattern) { $value=Copy-Fixture $sample; & $Edit $value; Save $value; Throws {Import-WelaCustomAuditProfiles $script:file} $Pattern }
try {
Save
$data=Import-WelaCustomAuditProfiles $script:file
Assert ($data.catalog.Count -eq 59 -and $data.customSource.Sha256.Length -eq 64 -and $data.customSource.Provenance -match 'Operator-declared') 'Custom references resolve to full authoritative catalog and exact-byte provenance.'
Assert (($data.catalog | Where-Object id -eq 'File System').prerequisites -match 'SACL') 'Custom file cannot erase canonical SACL prerequisites.'
$script:zero=@{}; foreach ($row in $data.catalog) {$script:zero[$row.guid]=0}
$process=($data.catalog | Where-Object id -eq 'Process Creation').guid
$termination=($data.catalog | Where-Object id -eq 'Process Termination').guid
$fileSystem=($data.catalog | Where-Object id -eq 'File System').guid
function Plan { Get-WelaAuditProfilePlan -Profile custom-example -Role Client -Build 26100 -Path $script:file -CustomFile -Current $script:state }
$script:state=$script:zero.Clone();$script:state[$process]=2
$plan=Plan
Assert (($plan.policies | Where-Object id -eq 'Process Creation').targetMask -eq 3) 'Minimum preserves an existing Failure flag.'
Assert (($plan.policies | Where-Object id -eq 'File System').action -eq 'Optional (not selected)') 'Unselected optional policy is preserved.'
Assert (($plan.policies | Where-Object id -eq 'Detailed File Share').action -eq 'Preserve') 'Not configured is never disabled.'
Assert (($plan.policies | Where-Object id -eq 'Kerberos Authentication Service').mode -eq 'not-applicable') 'Canonical DC applicability cannot be expanded by client source.'
$optional=Get-WelaAuditProfilePlan -Profile custom-example -Role Client -Build 26100 -Path $script:file -CustomFile -Current $script:state -IncludeOptional
Assert (($optional.policies | Where-Object id -eq 'File System').targetMask -eq 3) 'Explicit IncludeOptional selects the exact object-audit mask, retaining its SACL prerequisite.'
$override=Copy-Fixture $sample
$override.profiles[0].roleOverrides | Add-Member MemberServer ([pscustomobject]@{'Process Creation'=[pscustomobject]@{mode='exact';mask=2}})
Save $override
$member=Get-WelaAuditProfilePlan -Profile custom-example -Role MemberServer -Build 20348 -Path $script:file -CustomFile -Current $script:state
Assert (($member.policies | Where-Object id -eq 'Process Creation').requiredMask -eq 2 -and (Plan | Select-Object -ExpandProperty policies | Where-Object id -eq 'Process Creation').requiredMask -eq 1) 'Role override changes only its selected role.'
$reference=Copy-Fixture $sample; $reference.profiles[0] | Add-Member referenceOnly $true; Save $reference
$referencePlan=Plan
Throws {Assert-WelaAuditProfileTarget -Plan $referencePlan -Context ([pscustomobject]@{Role='Client';Build=26100}) -Current $script:state} 'reference'
Save
Bad {param($x) $x.schemaVersion='1'} 'schema'
Bad {param($x) $x | Add-Member command 'whoami'} 'Unknown'
Bad {param($x) $x.catalog[0].guid='0CCE922E-69AE-11D9-BED3-505054503030'} 'mismatch'
Bad {param($x) $x.catalog[0].id='process creation'} 'mismatch'
Bad {param($x) $x.catalog[0].category='Other'} 'mismatch'
Bad {param($x) $x.catalog[0] | Add-Member prerequisites ''} 'Unknown'
Bad {param($x) $x.catalog+=@($x.catalog[0])} 'Duplicate'
Bad {param($x) $x.profiles+=@($x.profiles[0])} 'Duplicate'
Bad {param($x) $x.profiles[0].id='wela-2.2.0'} 'built-in'
Bad {param($x) $x.profiles[0].sourceIds=@('missing')} 'Unknown'
Bad {param($x) $x.sources.organization.version=$null} 'text'
Bad {param($x) $x.sources.organization.url='file:///tmp/script.ps1'} 'HTTPS'
Bad {param($x) $x.profiles[0].appliesTo[0].minBuild='26100'} 'integers'
Bad {param($x) $x.profiles[0].appliesTo[0].maxBuild=1} 'Reversed'
Bad {param($x) $x.profiles[0].appliesTo[0].roles=@('Client','Client')} 'duplicate'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mask='1'} 'integer'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mask=$true} 'integer'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mask=4} 'integer'
Bad {param($x) $x.profiles[0].controls.'Process Creation'.mode='enable'} 'mode'
Bad {param($x) $x.profiles[0].controls.'Detailed File Share' | Add-Member mask 0} 'must not'
Bad {param($x) $x.profiles[0].controls | Add-Member 'RPC Events' ([pscustomobject]@{mode='exact';mask=3})} 'Unknown'
Bad {param($x) $x.profiles[0].controls.'Process Creation' | Add-Member script 'Write-Host bad'} 'Unknown'
Bad {param($x) $x.profiles[0] | Add-Member referenceOnly 'false'} 'boolean'
Save
# Pretty-print spacing differs between Windows PowerShell 5.1 and PowerShell 7.
# Compact JSON gives these lexical mutations stable tokens on both runtimes.
$text=$sample | ConvertTo-Json -Depth 20 -Compress
foreach ($badText in @($text.Replace('"mask":1','"mask":1,"MASK":2'),$text.Replace('"mask":1','"mask":1,"m\u0061sk":2'),$text.Replace('"schemaVersion":1','"schemaVersion":1,// comment'),$text.Replace('"mask":1','"mask":1,'))) {
Assert ($badText -cne $text) 'Malformed JSON fixture must change its input before rejection is tested.'
$badText | Set-Content -LiteralPath $script:file -Encoding UTF8
Throws {Import-WelaCustomAuditProfiles $script:file} 'Duplicate|strict JSON'
}
foreach ($badText in @($text.Replace('"schemaVersion"',"'schemaVersion'"),$text.Replace('"schemaVersion"','schemaVersion'),$text.Replace('"mask":1',"`"mask`":1,'mask':3"),$text.Replace('"mask":1','"mask":01'),$text.Replace('"mask":1','"mask":+1'))) {
Assert ($badText -cne $text) 'Invalid lexical JSON fixture must change its input before rejection is tested.'
$badText | Set-Content -LiteralPath $script:file -Encoding UTF8
Throws {Import-WelaCustomAuditProfiles $script:file} 'strict JSON'
}
$literal=Copy-Fixture $sample; $literal.profiles[0].note='$(throw "Never execute source data")'; Save $literal
Assert ((Import-WelaCustomAuditProfiles $script:file).profiles[0].note -ceq $literal.profiles[0].note) 'Executable-looking text stays literal inert metadata.'
Save; $source=(Import-WelaCustomAuditProfiles $script:file).customSource
Add-Content -LiteralPath $script:file -Value ' '
Throws {Assert-WelaCustomProfileSource $source} 'changed'
Save; $source=(Import-WelaCustomAuditProfiles $script:file).customSource; $source.CanonicalSha256='0'*64
Throws {Assert-WelaCustomProfileSource $source} 'changed'
$tokens=$null;$errors=$null
$ast=[Management.Automation.Language.Parser]::ParseFile((Join-Path $root 'WELA.ps1'),[ref]$tokens,[ref]$errors)
Assert ($errors.Count -eq 0) 'Public CLI parses.'
foreach ($name in @('Get-WelaSelectedContext','Show-WelaAuditProfilePrerequisites','Invoke-WelaProfileCommand')) {
$node=$ast.Find({param($n) $n -is [Management.Automation.Language.FunctionDefinitionAst] -and $n.Name -eq $name},$true)
. ([scriptblock]::Create($node.Extent.Text))
}
$script:nativeReads=0; $script:writes=0; $script:hostBuild=26100; $script:precedence=1; $script:mutateAtPrompt=$false
function TestWindows {$true}
function TestAdministrator {$true}
function Get-WelaHostContext {$script:nativeReads++;[pscustomobject]@{Role='Client';Build=$script:hostBuild}}
function Get-WelaEffectiveAuditPolicy {$script:nativeReads++;$script:state.Clone()}
function Get-WelaNativeAuditPolicy {param($Guid) $script:state[$Guid]}
function Get-WelaAuditPrecedenceSource {[pscustomobject]@{Description='mock';ConflictsWithRequiredValue=$false}}
function Get-WelaRegistryState {param($Path,$Name) [pscustomobject]@{ValueExists=$true;KeyExists=$true;Value=$script:precedence;Type='DWord'}}
function New-WelaRegistryKey {param($Path)}
function Set-ItemProperty {param($LiteralPath,$Name,$Value,$Type,$ErrorAction) $script:writes++;$script:precedence=$Value}
function Get-WelaTargetedSaclPlan {param($AuditPlan,$Mode,$Live) [pscustomobject]@{Mode='Skip';Targets=@();TelemetryGap='SACL proof absent'}}
function Invoke-WelaNative {
param($FilePath,$Arguments)
if ($FilePath -ne 'auditpol.exe') {throw 'Unexpected native command'}
$guid=($Arguments | Where-Object {$_ -like '/subcategory:*'}) -replace '^/subcategory:\{','' -replace '\}$',''
if ($Arguments -contains '/success:enable') {$script:state[$guid]=$script:state[$guid] -bor 1}
if ($Arguments -contains '/failure:enable') {$script:state[$guid]=$script:state[$guid] -bor 2}
if ($Arguments -contains '/success:disable') {$script:state[$guid]=$script:state[$guid] -band 2}
if ($Arguments -contains '/failure:disable') {$script:state[$guid]=$script:state[$guid] -band 1}
$script:writes++
}
function Read-Host {param($Prompt) if ($script:mutateAtPrompt) { Add-Content -LiteralPath $script:file -Value ' ';$script:mutateAtPrompt=$false };'y'}
$script:ProfileFile=$script:file;$script:Profile='custom-example';$script:Role='Client';$script:Build=26100
$script:Baseline=$null;$script:IncludeOptional=$false;$script:SaclMode='Skip';$script:Auto=$true;$script:DryRun=$true
$script:PlanPath=$null;$script:ResultsPath=$null;$script:BackupPath=$null
Save; $script:state=$script:zero.Clone()
$script:ResultsPath=Join-Path $temp 'readonly.json'
Invoke-WelaProfileCommand audit-settings | Out-Null
$readonly=Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($readonly.policies.Count -eq 59 -and $readonly.CustomProfileSource.Sha256) 'Custom read-only audit exports effective masks and selected source via ResultsPath.'
$script:ResultsPath=$null
Invoke-WelaProfileCommand configure | Out-Null
Assert ($script:writes -eq 0) 'Public custom configure DryRun performs no registry/audit mutation.'
Bad {param($x) $x.catalog[0].guid='bad'} 'mismatch'
$script:nativeReads=0
Throws {Invoke-WelaProfileCommand configure} 'mismatch'
Assert ($script:nativeReads -eq 0 -and $script:writes -eq 0) 'Malformed custom file is refused before host reads or configuration.'
Save;$script:Profile='wela-2.2.0'
Throws {Invoke-WelaProfileCommand configure} 'fallback'
Assert ($script:nativeReads -eq 0) 'Selected built-in profile cannot silently override file selection.'
$script:Profile='custom-example';$script:Build=17763
Throws {Invoke-WelaProfileCommand configure} 'does not support'
Assert ($script:nativeReads -eq 0) 'Explicit unsupported target is refused before host reads.'
$script:Build=26100;$script:ResultsPath=Join-Path $temp './profile.json'
Throws {Invoke-WelaProfileCommand configure} 'paths must differ'
$script:ResultsPath=$null
# Existing hard links are distinct names for the same source bytes. Neither
# output aliases nor input aliases may evade source protection before reads.
$alias=Join-Path $temp 'source-alias.json'
$null=New-Item -ItemType HardLink -Path $alias -Value $script:file
$sourceHash=(Get-FileHash $script:file).Hash;$script:nativeReads=0
$script:ResultsPath=$alias
Throws {Invoke-WelaProfileCommand plan} 'output already exists'
$script:ProfileFile=$alias;$script:ResultsPath=$script:file
Throws {Invoke-WelaProfileCommand plan} 'output already exists'
Assert ($script:nativeReads -eq 0 -and (Get-FileHash $script:file).Hash -ceq $sourceHash) 'Alias collisions preserve source bytes and fail before host reads.'
$script:ProfileFile=$script:file;$script:ResultsPath=$null
Remove-Item -LiteralPath $alias
$script:PlanPath=Join-Path $temp 'same-output.json';$script:ResultsPath=$script:PlanPath
Throws {Invoke-WelaProfileCommand plan} 'distinct new report files'
$script:PlanPath=$null;$script:ResultsPath=$null
$reportTarget=Join-Path $temp 'protected-report.json'
Write-WelaCustomProfileReport ([pscustomobject]@{status='original'}) $reportTarget
Throws {Write-WelaCustomProfileReport ([pscustomobject]@{status='replacement'}) $reportTarget} 'output already exists'
Assert ((Get-Content $reportTarget -Raw|ConvertFrom-Json).status -eq 'original') 'Final report writer preserves existing artifacts instead of overwriting aliases.'
$script:state=$script:zero.Clone();$script:state[$process]=2;$script:state[$termination]=3;$script:precedence=0
$script:BackupPath=Join-Path $temp 'applied';$script:ResultsPath=Join-Path $temp 'applied.json';$script:DryRun=$false
Invoke-WelaProfileCommand configure | Out-Null
$report=Get-Content -LiteralPath $script:ResultsPath -Raw | ConvertFrom-Json
Assert ($report.ExitCode -eq 0 -and $script:state[$process] -eq 3 -and $script:state[$termination] -eq 1 -and $script:state[$fileSystem] -eq 0 -and $script:precedence -eq 1) 'Shared engine preserves minimum bits, applies exact bits and precedence, and omits optional SACL policy.'
Assert ($report.CustomProfileSource.Sha256 -ceq $report.SchemaSha256) 'Applied result retains selected input provenance.'
$entries=@(Get-Content -LiteralPath (Join-Path $script:BackupPath 'before.jsonl') | ForEach-Object {$_ | ConvertFrom-Json})
Assert ($entries.Count -eq 3 -and @($entries | Where-Object {$_.CustomProfileSource.Sha256 -cne $report.SchemaSha256}).Count -eq 0) 'Every prerequisite/audit journal entry records the selected file hash.'
$script:BackupPath=Join-Path $temp 'prompt-race';$script:ResultsPath=Join-Path $temp 'race.json';$script:Auto=$false;$script:mutateAtPrompt=$true
$script:state=$script:zero.Clone();$script:precedence=0;$script:writes=0
Throws {Invoke-WelaProfileCommand configure} 'failed'
Assert ($script:writes -eq 0) 'File replacement during confirmation refuses precedence and dependent writes.'
Save;$plan=Plan;$ctx=New-WelaConfigurationContext -DryRun
$script:hostBuild=26200
Throws {Set-WelaProfileAuditControls -Context $ctx -Plan $plan} 'target role/build changed'
$script:hostBuild=26100;$script:state[$process]=1;$script:state[$termination]=1;$script:precedence=1;$plan=Plan
$ctx=New-WelaConfigurationContext -DryRun
Set-WelaProfileAuditControls $ctx $plan
Add-Content -LiteralPath $script:file -Value ' '
$final=Complete-WelaConfiguration -Context $ctx -Plan $plan
Assert ($final.ExitCode -eq 1 -and $final.Failed -ge 1) 'Final verification detects input drift after initially compliant controls.'
Save
$exe=(Get-Process -Id $PID).Path
foreach ($arguments in @(@('configure','-ProfileFile',$script:file),@('configure-sacl','-ProfileFile',$script:file),@('configure','-Profile','custom-example','-ProfileFile',$script:file,'-OutgoingNtlmMode','Deny'))) {
$ErrorActionPreference='Continue';try {$output=& $exe -NoProfile -File (Join-Path $root 'WELA.ps1') @arguments 2>&1;$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
Assert ($code -ne 0 -and ($output -join "`n") -match 'ProfileFile|Unsupported option') 'Wrong/missing custom profile options stop public dispatch.'
}
Write-Host "PASS: $script:count custom-profile assertions; all native mutations were mocked."
} finally {Remove-Item -LiteralPath $temp -Recurse -Force}
$global:LASTEXITCODE=0