mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-02 04:24:44 +02:00
* Add guarded DNS analytical channel lifecycle and trace archives * Link DNS analytical changelogs to PR425 * Fix native DNS archive inspection and guard artifact paths * Diagnose exact DNS event envelope from stopped native trace * Verify DNS ETL event provenance without inventing XML channel * Preserve exact UTC timestamp strings in shared evidence fixtures
136 lines
15 KiB
PowerShell
136 lines
15 KiB
PowerShell
param([switch]$AllowDisposableDns,[ValidateSet('powershell','pwsh')][string]$TestEngine='powershell')
|
|
$ErrorActionPreference='Stop'
|
|
if(-not $AllowDisposableDns -or $env:GITHUB_ACTIONS -ne 'true' -or $env:RUNNER_ENVIRONMENT -ne 'github-hosted' -or $env:OS -ne 'Windows_NT'){throw 'Explicit DNS mutation opt-in on a disposable GitHub-hosted Windows runner is required.'}
|
|
$root=Split-Path $PSScriptRoot -Parent
|
|
Import-Module (Join-Path $root 'modules/AuditProfiles.psm1') -Force
|
|
Import-Module (Join-Path $root 'modules/NativeProviders.psm1') -Force
|
|
. (Join-Path $root 'scripts/Configuration.ps1')
|
|
. (Join-Path $root 'scripts/ControlApplicability.ps1')
|
|
. (Join-Path $root 'scripts/NativeProviderPacks.ps1')
|
|
. (Join-Path $root 'scripts/DnsAnalytical.ps1')
|
|
$script:count=0
|
|
function Assert($Value,$Message){if(-not $Value){throw $Message};$script:count++}
|
|
function Throws($Code,$Pattern){$message='';try{& $Code|Out-Null}catch{$message=$_.Exception.Message};Assert ($message -match $Pattern) "Expected $Pattern, got $message"}
|
|
$hostOs=Get-CimInstance Win32_OperatingSystem;$hostComputer=Get-CimInstance Win32_ComputerSystem
|
|
if($hostOs.ProductType -ne 3 -or [int]$hostOs.BuildNumber -notin @(20348,26100) -or $hostComputer.PartOfDomain -or $hostComputer.DomainRole -ne 2 -or (Get-WindowsFeature DNS).Installed){throw 'This disposable test requires an unjoined Server2022/2025 with no pre-existing DNS role.'}
|
|
$engine=(Get-Command $TestEngine -ErrorAction Stop).Source
|
|
$nonce=[guid]::NewGuid().ToString('N');$zone='wela-'+$nonce+'.test';$query='probe.'+$zone;$zoneFile=$zone+'.dns'
|
|
$private=New-WelaDnsAnalyticalBackup (Join-Path $env:TEMP ('wela-dns-native-'+$nonce))
|
|
$beforeFeatures=@(Get-WindowsFeature|Where-Object Installed|ForEach-Object Name);$beforePolicies=Get-WelaEffectiveAuditPolicy
|
|
$installed=$false;$zoneCreated=$false;$original=$null;$passed=$false
|
|
function Invoke-Cli {
|
|
param([string[]]$Arguments,[int]$Expected=0)
|
|
$ErrorActionPreference='Continue'
|
|
try{$text=@(& $engine -NoProfile -File (Join-Path $root 'WELA.ps1') @Arguments 2>&1);$code=$LASTEXITCODE}finally{$ErrorActionPreference='Stop'}
|
|
$text|ForEach-Object{Write-Host $_};$global:LASTEXITCODE=0
|
|
Assert ($code -eq $Expected) "Public DNS CLI exit $code, expected $Expected."
|
|
}
|
|
try {
|
|
# Real file-handle/archive behavior uses only owned temporary bytes.
|
|
$source=Join-Path $private 'stream-source.etl';$destination=Join-Path $private 'stream-archive.etl'
|
|
$bytes=New-Object byte[] 131073;for($i=0;$i -lt $bytes.Length;$i++){$bytes[$i]=[byte]($i%251)};[IO.File]::WriteAllBytes($source,$bytes)
|
|
$archive=Copy-WelaDnsAnalyticalTrace $source $destination 1048576
|
|
Assert ($archive.State -eq 'ArchivedBytes' -and $archive.Length -eq 131073 -and $archive.Sha256 -ceq (Get-FileHash $source).Hash.ToLowerInvariant() -and $archive.Sha256 -ceq $archive.ArchivedSha256) 'Streaming native archive retains exact bytes and hashes.'
|
|
Assert-WelaDnsAnalyticalArchive $archive 1048576
|
|
$observed=Copy-WelaDnsAnalyticalTrace -Source $source -MaximumBytes 1048576
|
|
Assert ($observed.State -eq 'ObservedBytes' -and $observed.Sha256 -ceq $archive.Sha256 -and -not$observed.ArchivePath) 'Native read-only overload preserves bytes without a destination or null-string coercion.'
|
|
foreach($alias in @(($source+':stream'),($source+'.'),($source+' '),(Join-Path $private 'bad?.etl'))){Throws {Resolve-WelaDnsAnalyticalOutput $alias} 'unsupported'}
|
|
Throws {Copy-WelaDnsAnalyticalTrace $source $destination 1048576} 'already exists|exist'
|
|
$lock=[IO.File]::Open($source,[IO.FileMode]::Open,[IO.FileAccess]::ReadWrite,[IO.FileShare]::None)
|
|
try{Throws {Copy-WelaDnsAnalyticalTrace -Source $source -MaximumBytes 1048576} 'absence was not established|another process'}finally{$lock.Dispose()}
|
|
$junctionTarget=Join-Path $private 'junction-target';$junction=Join-Path $private 'junction'
|
|
$null=New-Item -ItemType Directory -Path $junctionTarget
|
|
[IO.File]::WriteAllText((Join-Path $junctionTarget 'trace.etl'),'owned link fixture')
|
|
$null=New-Item -ItemType Junction -Path $junction -Value $junctionTarget
|
|
try{Throws {Copy-WelaDnsAnalyticalTrace -Source (Join-Path $junction 'trace.etl') -MaximumBytes 1048576} 'reparse'}finally{(Get-Item -LiteralPath $junction -Force).Delete()}
|
|
$absent=Copy-WelaDnsAnalyticalTrace -Source (Join-Path $private 'absent.etl') -MaximumBytes 1048576
|
|
Assert ($absent.State -eq 'ObservedAbsent' -and -not $absent.Sha256) 'Native FILE_NOT_FOUND is typed absence without fabricated archive/hash.'
|
|
$empty=Join-Path $private 'empty.etl';[IO.File]::WriteAllBytes($empty,[byte[]]@())
|
|
Throws {Copy-WelaDnsAnalyticalTrace -Source $empty -MaximumBytes 1048576} 'empty archive'
|
|
$large=Join-Path $private 'large.etl';$s=[IO.File]::Create($large);try{$s.SetLength(1048577)}finally{$s.Dispose()}
|
|
Throws {Copy-WelaDnsAnalyticalTrace -Source $large -MaximumBytes 1048576} 'cap'
|
|
Throws {Copy-WelaDnsAnalyticalTrace -Source (Join-Path $private 'missing-parent\trace.etl') -MaximumBytes 1048576} 'find|exist'
|
|
# The DNS role and zone belong exclusively to this disposable runner.
|
|
$installed=$true;$feature=Install-WindowsFeature DNS -IncludeManagementTools -ErrorAction Stop
|
|
if(-not $feature.Success -or [string]$feature.RestartNeeded -ne 'No'){throw 'DNS role installation failed or needs a restart; no native acceptance claim.'}
|
|
Start-Service DNS -ErrorAction Stop
|
|
$definition=Get-WelaDnsAnalyticalDefinition;$original=Get-WelaDnsAnalyticalState $definition
|
|
Write-WelaDnsAnalyticalJson (Join-Path $private 'original-channel.json') $original
|
|
$zoneCreated=$true
|
|
Add-DnsServerPrimaryZone -Name $zone -ZoneFile $zoneFile -DynamicUpdate None -ErrorAction Stop
|
|
Add-DnsServerResourceRecordA -ZoneName $zone -Name probe -IPv4Address '127.0.0.42' -TimeToLive ([TimeSpan]::FromMinutes(1)) -ErrorAction Stop|Out-Null
|
|
$configuredPath=Join-Path $private 'configured.json'
|
|
Invoke-Cli @('dns-analytical','-DnsAction','Configure','-DnsState','Enabled','-DnsRetention','Retain','-AllowDnsTraceReset','-Auto','-BackupPath',(Join-Path $private 'enable-journal'),'-ResultsPath',$configuredPath)
|
|
$configured=Get-Content $configuredPath -Raw -Encoding UTF8|ConvertFrom-Json
|
|
Assert ($configured.Status -eq 'Applied' -and $configured.After.IsEnabled -and $configured.After.LogMode -eq 'Retain' -and $configured.ReadyRuleCredit -eq 0) 'Actual public configure enables only the reviewed analytical channel without readiness credit.'
|
|
Assert ($configured.After.SecurityDescriptor -ceq $original.SecurityDescriptor -and $configured.After.LogFilePath -ceq $original.LogFilePath) 'Native transition preserves ACL and registered local trace path.'
|
|
$repeatPath=Join-Path $private 'repeat.json'
|
|
Invoke-Cli @('dns-analytical','-DnsAction','Configure','-DnsState','Enabled','-DnsRetention','Retain','-AllowDnsTraceReset','-Auto','-BackupPath',(Join-Path $private 'unused-repeat-journal'),'-ResultsPath',$repeatPath)
|
|
$repeat=Get-Content $repeatPath -Raw -Encoding UTF8|ConvertFrom-Json
|
|
Assert ($repeat.Status -eq 'AlreadyCompliant' -and -not(Test-Path (Join-Path $private 'unused-repeat-journal'))) 'Actual repeat neither restarts nor creates misleading recovery evidence.'
|
|
$started=[DateTime]::UtcNow
|
|
$answers=@(Resolve-DnsName -Name $query -Type A -Server '127.0.0.1' -DnsOnly -NoHostsFile -ErrorAction Stop)
|
|
Assert (@($answers|Where-Object IPAddress -eq '127.0.0.42').Count -eq 1) 'Unique authoritative .test query is resolved entirely through loopback.'
|
|
# Stop and archive through the public lifecycle before reading the ETL.
|
|
$disabledPath=Join-Path $private 'disabled.json'
|
|
Invoke-Cli @('dns-analytical','-DnsAction','Configure','-DnsState','Disabled','-AllowDnsTraceReset','-Auto','-BackupPath',(Join-Path $private 'disable-journal'),'-ResultsPath',$disabledPath)
|
|
$disabled=Get-Content $disabledPath -Raw -Encoding UTF8|ConvertFrom-Json
|
|
Assert ($disabled.Status -eq 'Applied' -and -not$disabled.After.IsEnabled -and $disabled.Archive.State -eq 'ArchivedBytes') 'Native disable preserves stopped trace in a hash-verified archive.'
|
|
Assert ($disabled.Archive.SourcePath -ceq $original.LogFilePath -and $disabled.Before.Channel -ceq $definition.Pack.channel -and $disabled.After.Channel -ceq $definition.Pack.channel) 'ETL bytes came from the exact registered analytical trace path and preserved channel state.'
|
|
$events=@(Get-WinEvent -Path $disabled.Archive.ArchivePath -Oldest -MaxEvents 4096 -ErrorAction Stop)
|
|
Assert ($events.Count -lt 4096) 'Archived native event read stays below its explicit completeness cap.'
|
|
$candidateXml=New-Object 'System.Collections.Generic.List[string]'
|
|
$eventSummary=New-Object 'System.Collections.Generic.List[string]'
|
|
$matches=@(foreach($event in $events){
|
|
try{
|
|
$rawXml=$event.ToXml();$xml=[xml]$rawXml;$data=@{};foreach($node in $xml.Event.EventData.Data){$data[[string]$node.Name]=[string]$node.'#text'}
|
|
if($eventSummary.Count -lt 20){$eventSummary.Add("Event $($event.Id), provider $($event.ProviderName), channel '$($xml.Event.System.Channel)', QNAME '$($data.QNAME)', time $($event.TimeCreated.ToUniversalTime().ToString('o'))")}
|
|
if($event.Id -eq 257 -and $candidateXml.Count -lt 8){$candidateXml.Add($rawXml)}
|
|
# Raw ETL rendering can leave System.Channel empty. Do not rewrite it: bind
|
|
# provenance to the verified registered trace path above and exact native manifest link.
|
|
$manifest=@($disabled.Before.Schema.Events|Where-Object {$_.Id -eq $event.Id -and $_.Version -eq $event.Version -and $_.Channel -ceq $definition.Pack.channel})
|
|
$channel=[string]$xml.Event.System.Channel
|
|
if($event.Id -eq 257 -and $event.ProviderName -ceq 'Microsoft-Windows-DNSServer' -and [string]$xml.Event.System.Provider.Guid -ieq '{eb79061a-a566-4698-9119-3ed2807060e7}' -and $manifest.Count -eq 1 -and ($channel -ceq '' -or $channel -ceq $definition.Pack.channel) -and [string]$xml.Event.System.Computer -ieq [Environment]::MachineName -and $event.TimeCreated.ToUniversalTime() -ge $started -and $event.TimeCreated.ToUniversalTime() -le [DateTime]::UtcNow -and [string]$data.QNAME.TrimEnd('.') -ieq $query -and $data.InterfaceIP -ceq '127.0.0.1' -and $data.Destination -ceq '127.0.0.1' -and $data.QTYPE -ceq '1' -and $data.RCODE -ceq '0' -and $data.AA -ceq '1'){$rawXml}
|
|
}finally{if($event -is [IDisposable]){$event.Dispose()}}
|
|
})
|
|
if($matches.Count -ne 1){Write-Host "Expected one event257 for $query since $($started.ToString('o')); matched $($matches.Count) from $($events.Count) events.";$eventSummary|ForEach-Object{Write-Host $_};$candidateXml|ForEach-Object{Write-Host $_};throw 'Exact bounded native DNS257/QNAME evidence was not established.'}
|
|
[IO.File]::WriteAllText((Join-Path $private 'event257.xml'),$matches[0],[Text.UTF8Encoding]::new($false));Write-Host $matches[0]
|
|
$proof=[pscustomobject]@{Kind='WelaDnsAnalyticalNativeProbe';Engine=$TestEngine;Computer=[Environment]::MachineName;Query=$query;StartedUtc=$started.ToString('o');VerifiedUtc=[DateTime]::UtcNow.ToString('o');EventId=257;XmlChannel=[string]([xml]$matches[0]).Event.System.Channel;RegisteredChannel=$definition.Pack.channel;RegisteredTracePath=$original.LogFilePath;ChannelEvidence='Verified registered ETL path and provider event/version manifest link; raw XML channel is retained without normalization.';ArchiveSha256=$disabled.Archive.Sha256;ReadyRuleCredit=0}
|
|
Write-WelaDnsAnalyticalJson (Join-Path $private 'event257-proof.json') $proof
|
|
Write-Host ($proof|ConvertTo-Json -Depth 6)
|
|
Assert ((Get-FileHash $disabled.Archive.ArchivePath).Hash.ToLowerInvariant() -ceq $disabled.Archive.Sha256) 'Collected native ETL retains the recorded archive hash.'
|
|
$passed=$true
|
|
Write-Host "PASS: $script:count DNS native assertions through $TestEngine; exact257 XML observed, no external DNS query or backend/readiness claim."
|
|
}catch{Write-Host ('DNS native test failure before cleanup: '+($_|Out-String));Write-Host $_.ScriptStackTrace;throw}
|
|
finally {
|
|
$errors=@()
|
|
if($original){try{
|
|
$current=Get-WelaDnsAnalyticalState $definition
|
|
# Fixture cleanup is explicit, confined to the DNS role created above.
|
|
if($current.IsEnabled){Set-WelaDnsAnalyticalNative @('sl',$current.Channel,'/e:false')}
|
|
$preserved=Copy-WelaDnsAnalyticalTrace -Source $current.LogFilePath -Destination (Join-Path $private 'cleanup-trace.etl') -MaximumBytes 1073741824
|
|
Write-WelaDnsAnalyticalJson (Join-Path $private 'cleanup-archive.json') $preserved
|
|
Assert-WelaDnsAnalyticalArchive $preserved 1073741824
|
|
$args=@('sl',$original.Channel,('/ms:'+[string]$original.MaximumSizeInBytes),('/rt:'+([string]($original.LogMode -eq 'Retain')).ToLowerInvariant()),('/e:'+([string]$original.IsEnabled).ToLowerInvariant()),'/q:true')
|
|
Set-WelaDnsAnalyticalNative $args
|
|
if((Get-WelaDnsAnalyticalStateKey (Get-WelaDnsAnalyticalState $definition)) -cne (Get-WelaDnsAnalyticalStateKey $original)){throw 'Exact DNS channel configuration restoration differs.'}
|
|
}catch{$errors+=$_.Exception.Message}}
|
|
if($zoneCreated){try{
|
|
$owned=Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue
|
|
if($owned){if($owned.IsDsIntegrated -or $owned.ZoneType -ne 'Primary'){throw 'Owned DNS zone identity changed; cleanup refused.'};Remove-DnsServerZone -Name $zone -Force -ErrorAction Stop}
|
|
if(Get-DnsServerZone -Name $zone -ErrorAction SilentlyContinue){throw 'Owned test zone remains.'}
|
|
$file=Join-Path $env:SystemRoot ('System32\dns\'+$zoneFile);if(Test-Path -LiteralPath $file){Remove-Item -LiteralPath $file -ErrorAction Stop}
|
|
}catch{$errors+=$_.Exception.Message}}
|
|
try{$afterPolicies=Get-WelaEffectiveAuditPolicy;foreach($guid in $beforePolicies.Keys){if($afterPolicies[$guid] -ne $beforePolicies[$guid]){throw 'Native audit policy unexpectedly changed.'}}}catch{$errors+=$_.Exception.Message}
|
|
$removal=[pscustomobject]@{ChannelAndZoneRestored=($errors.Count -eq 0);Attempted=$false;Features=@();Success=$null;RestartNeeded=$null;Boundary='Feature removal can await disposal of this GitHub-hosted VM; no production restart or complete live feature-restoration claim.'}
|
|
if($installed -and $errors.Count -eq 0){try{
|
|
$added=@(Get-WindowsFeature|Where-Object{$_.Installed -and $_.Name -notin $beforeFeatures -and $_.Name -in @('DNS','RSAT-DNS-Server')}|ForEach-Object Name)
|
|
if($added.Count){$removal.Attempted=$true;$removal.Features=$added;$removed=Uninstall-WindowsFeature -Name $added -ErrorAction Stop;$removal.Success=[bool]$removed.Success;$removal.RestartNeeded=[string]$removed.RestartNeeded;if(-not $removed.Success -or $removal.RestartNeeded -notin @('No','Yes')){throw 'Created DNS feature removal failed or restart status is unknown.'}}
|
|
}catch{$errors+=$_.Exception.Message}}
|
|
Write-WelaDnsAnalyticalJson (Join-Path $private 'feature-removal.json') $removal;$removal|ConvertTo-Json -Depth 5|Write-Host
|
|
if($errors.Count){throw "Disposable DNS cleanup failed; receipts retained at $private : $($errors -join '; ')"}
|
|
if($passed){Remove-Item -LiteralPath $private -Recurse -Force}
|
|
}
|
|
$global:LASTEXITCODE=0
|
|
Write-Host 'PASS: exact native channel configuration and audit state restored; owned DNS zone/records removed. Feature removal/disposal boundary recorded separately.'
|