mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-01 03:54:44 +02:00
212 lines
14 KiB
PowerShell
212 lines
14 KiB
PowerShell
$ErrorActionPreference = 'Stop'
|
|
$repo = Split-Path $PSScriptRoot -Parent
|
|
$script:ScriptRoot = $repo
|
|
. (Join-Path $repo 'scripts/Configuration.ps1')
|
|
. (Join-Path $repo 'scripts/FirewallLogging.ps1')
|
|
$script:assertions = 0
|
|
$script:paths = @()
|
|
function Assert($Condition, [string]$Message) {
|
|
if (-not $Condition) { throw "FAIL: $Message" }
|
|
$script:assertions++
|
|
}
|
|
function New-Entry($Name, $Size = 4096) {
|
|
[pscustomobject]@{ Name = $Name; LogAllowed = 'False'; LogBlocked = 'False'; LogMaxSizeKilobytes = $Size; LogFileName = "C:\Operator\$Name.log"; Enabled = 'False' }
|
|
}
|
|
function Reset-Mocks {
|
|
$script:localProfiles = @{}; $script:effectiveProfiles = @{}
|
|
foreach ($name in @('Domain', 'Private', 'Public')) {
|
|
$script:localProfiles[$name] = New-Entry $name
|
|
$script:effectiveProfiles[$name] = New-Entry $name
|
|
}
|
|
$script:writes = 0; $script:writeArguments = @(); $script:blockedAccess = $false
|
|
$script:failRead = $false; $script:failWrite = $false; $script:gpo = $false; $script:onPrompt = $null
|
|
$script:accessCalls = 0
|
|
$script:unverifiedPaths = @()
|
|
}
|
|
function Get-NetFirewallProfile {
|
|
param($Name, $PolicyStore, $ErrorAction)
|
|
if ($script:failRead) { throw 'Read denied' }
|
|
if ($PolicyStore -eq 'ActiveStore') { return $script:effectiveProfiles[$Name] }
|
|
if ($PolicyStore -eq 'PersistentStore') { return $script:localProfiles[$Name] }
|
|
throw "Unexpected store $PolicyStore"
|
|
}
|
|
function Set-NetFirewallProfile {
|
|
param($Name, $PolicyStore, $LogAllowed, $LogBlocked, $LogMaxSizeKilobytes, $LogFileName, $ErrorAction)
|
|
Assert ($PolicyStore -eq 'PersistentStore') 'Only local persistent policy is written'
|
|
$entries = @(Get-Content -LiteralPath (Join-Path $script:context.BackupPath 'before.jsonl') | ConvertFrom-Json)
|
|
Assert ($entries[-1].Target.Name -eq $Name) 'Matching recovery snapshot exists before each write'
|
|
Assert ($entries[-1].Before.Local.Name -eq $Name -and $entries[-1].Before.Effective.Name -eq $Name) 'Journal includes local and effective snapshots'
|
|
if ($script:failWrite) { throw 'Mock policy write failed' }
|
|
$script:writes++
|
|
$script:writeArguments += $PSBoundParameters
|
|
foreach ($store in @($script:localProfiles, $script:effectiveProfiles)) {
|
|
if ($script:gpo -and [object]::ReferenceEquals($store, $script:effectiveProfiles)) { continue }
|
|
$store[$Name].LogAllowed = $LogAllowed; $store[$Name].LogBlocked = $LogBlocked; $store[$Name].LogMaxSizeKilobytes = $LogMaxSizeKilobytes
|
|
if ($PSBoundParameters.ContainsKey('LogFileName')) { $store[$Name].LogFileName = $LogFileName }
|
|
}
|
|
}
|
|
function Get-WelaFirewallLogAccess {
|
|
param($Path)
|
|
$script:accessCalls++
|
|
[pscustomobject]@{ State = $(if ($script:blockedAccess -or $script:unverifiedPaths -contains $Path) { 'Unknown' } else { 'VerifiedExplicitGrant' }); Path = $Path; Diagnostic = 'Mock service ACL'; ServiceAccount = 'NT AUTHORITY\LocalService'; ServiceStatus = 'Running' }
|
|
}
|
|
function Read-Host {
|
|
param($Prompt)
|
|
if ($script:onPrompt) { & $script:onPrompt }
|
|
'Y'
|
|
}
|
|
function New-TestContext([switch]$DryRun, [switch]$Prompt) {
|
|
$path = Join-Path ([IO.Path]::GetTempPath()) ('wela-firewall-' + [guid]::NewGuid().ToString('N'))
|
|
$script:paths += $path
|
|
$script:context = New-WelaConfigurationContext -Auto:(-not $Prompt) -DryRun:$DryRun -BackupPath $path
|
|
return $script:context
|
|
}
|
|
function New-Ace($Sid, $Rights, $Inheritance = 'ObjectInherit', $Propagation = 'None', $Type = 'Allow') {
|
|
[pscustomobject]@{ IdentityReference = [pscustomobject]@{ Value = $Sid }; FileSystemRights = [Security.AccessControl.FileSystemRights]$Rights; InheritanceFlags = [Security.AccessControl.InheritanceFlags]$Inheritance; PropagationFlags = [Security.AccessControl.PropagationFlags]$Propagation; AccessControlType = $Type }
|
|
}
|
|
try {
|
|
$sid = 'S-1-5-80-123'
|
|
$good = New-Ace $sid 'Modify'
|
|
Assert (Test-WelaFirewallServiceAcl @($good) $sid -Directory) 'Service Modify grant covers directory creation/rotation and inherited files'
|
|
Assert (-not (Test-WelaFirewallServiceAcl @((New-Ace $sid 'Read')) $sid -Directory)) 'Read-only service permission is unknown'
|
|
Assert (-not (Test-WelaFirewallServiceAcl @((New-Ace 'S-1-1-0' 'FullControl')) $sid -Directory)) 'Broad group grant is not mistaken for proven service-token access'
|
|
Assert (-not (Test-WelaFirewallServiceAcl @((New-Ace $sid 'Modify' 'None')) $sid -Directory)) 'New log files require inheritable service permission'
|
|
Assert (Test-WelaFirewallServiceAcl @((New-Ace $sid 'Modify' 'None')) $sid) 'Existing file needs self access, not inheritance'
|
|
Assert (-not (Test-WelaFirewallServiceAcl @((New-Ace $sid 'Modify' 'ObjectInherit' 'InheritOnly')) $sid -Directory)) 'Inherit-only grant does not permit directory access'
|
|
Assert (-not (Test-WelaFirewallServiceAcl @($good, (New-Ace 'S-1-1-0' 'Write' 'None' 'None' 'Deny')) $sid -Directory)) 'Any unresolved group deny prevents claiming access'
|
|
|
|
Reset-Mocks
|
|
$plan = @(Get-WelaFirewallLoggingPlan)
|
|
Assert ($plan.Count -eq 3 -and ($plan.Name -join ',') -eq 'Domain,Private,Public') 'All profiles are planned'
|
|
Assert (@($plan | Where-Object Status -eq ChangeRequired).Count -eq 3) 'Existing logging gaps are explicit'
|
|
Assert ($plan[0].Desired.MinimumSizeKiB -eq 16384 -and $plan[0].Desired.LogFileName -eq 'C:\Operator\Domain.log') 'Default plan retains operator path and minimum semantics'
|
|
$context = New-TestContext -DryRun
|
|
Set-WelaFirewallLoggingControls $context $plan
|
|
Assert ($script:writes -eq 0 -and $context.Results.Count -eq 3) 'Dry run does not change any profile'
|
|
Assert (-not (Test-Path -LiteralPath $context.BackupPath)) 'Dry run creates no recovery directory'
|
|
|
|
Reset-Mocks
|
|
$script:localProfiles.Domain.LogMaxSizeKilobytes = 24576
|
|
$script:effectiveProfiles.Domain.LogMaxSizeKilobytes = 20480
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context @(Get-WelaFirewallLoggingPlan)
|
|
$result = Complete-WelaConfiguration $context -Scope firewall-text-logging-only
|
|
Assert ($result.ExitCode -eq 0 -and $script:writes -eq 3) 'All three profiles are verified after writing'
|
|
Assert ($script:effectiveProfiles.Domain.LogMaxSizeKilobytes -eq 24576) 'Larger local and effective limits are preserved'
|
|
Assert ($script:effectiveProfiles.Private.LogMaxSizeKilobytes -eq 16384) 'Smaller limit is raised to minimum'
|
|
Assert ($script:effectiveProfiles.Domain.Enabled -eq 'False') 'Disabled firewall enforcement remains untouched'
|
|
Assert (@($script:writeArguments | Where-Object { $_.ContainsKey('LogFileName') }).Count -eq 0) 'Default configuration never writes path parameter'
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context @(Get-WelaFirewallLoggingPlan)
|
|
Assert ($script:writes -eq 3 -and @($context.Results | Where-Object Status -eq AlreadyCompliant).Count -eq 3) 'Verified settings are idempotent'
|
|
$script:effectiveProfiles.Public.LogAllowed = 'False'
|
|
Assert ((Complete-WelaConfiguration $context).ExitCode -eq 1 -and $context.Results[2].Status -eq 'Overridden') 'Final effective-policy drift fails the run'
|
|
|
|
Reset-Mocks
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context @(Get-WelaFirewallLoggingPlan -PathMode CisV4 -MinimumSizeKiB 20480)
|
|
Assert ($script:writes -eq 3) 'Explicit CIS path configuration writes all profiles'
|
|
foreach ($name in @('Domain', 'Private', 'Public')) {
|
|
Assert ($script:effectiveProfiles[$name].LogFileName -eq ('%SystemRoot%\System32\LogFiles\Firewall\' + $name.ToLowerInvariant() + 'fw.log')) 'Explicit CIS path is distinct for each profile'
|
|
}
|
|
Assert ($script:effectiveProfiles.Public.LogMaxSizeKilobytes -eq 20480) 'Operator can choose the higher Microsoft size recommendation'
|
|
|
|
Reset-Mocks
|
|
$script:gpo = $true
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context @(Get-WelaFirewallLoggingPlan)
|
|
$result = Complete-WelaConfiguration $context
|
|
Assert ($result.ExitCode -eq 1 -and $result.Failed -eq 3) 'Local success with ineffective GPO-overridden settings is failure'
|
|
Assert ($context.Results[0].After.DifferentFromLocal -contains 'LogAllowed') 'Results expose effective versus local differences'
|
|
|
|
Reset-Mocks
|
|
$script:blockedAccess = $true
|
|
$plan = @(Get-WelaFirewallLoggingPlan)
|
|
Assert (@($plan | Where-Object Status -eq Unknown).Count -eq 3) 'Unverified service access is Unknown'
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context $plan
|
|
Assert ($script:writes -eq 0 -and (Complete-WelaConfiguration $context).Failed -eq 3) 'Unknown directory permissions block writes without broadening ACLs'
|
|
|
|
Reset-Mocks
|
|
$script:failRead = $true
|
|
$plan = @(Get-WelaFirewallLoggingPlan)
|
|
Assert (@($plan | Where-Object Status -eq Unknown).Count -eq 3) 'Read failure never becomes a configured/default value'
|
|
$context = New-TestContext -DryRun
|
|
Set-WelaFirewallLoggingControls $context $plan
|
|
Assert ($script:writes -eq 0 -and (Complete-WelaConfiguration $context).ExitCode -eq 1) 'Unreadable dry run still reports failure'
|
|
|
|
Reset-Mocks
|
|
$script:failWrite = $true
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context @(Get-WelaFirewallLoggingPlan)
|
|
Assert ((Complete-WelaConfiguration $context).Failed -eq 3) 'Write failures are aggregated and controls continue'
|
|
|
|
Reset-Mocks
|
|
$script:onPrompt = { $script:localProfiles.Domain.LogFileName = 'C:\NewOperator\Domain.log' }
|
|
$context = New-TestContext -Prompt
|
|
Set-WelaFirewallLoggingControls $context @((Get-WelaFirewallLoggingPlan)[0])
|
|
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Operator changes during prompt prevent stale recovery snapshot writes'
|
|
|
|
foreach ($unverified in @($true, $false)) {
|
|
Reset-Mocks
|
|
$plan = @((Get-WelaFirewallLoggingPlan)[0])
|
|
$newPath = 'C:\ChangedAfterPlan\Domain.log'
|
|
$script:effectiveProfiles.Domain.LogFileName = $newPath
|
|
$script:localProfiles.Domain.LogFileName = $newPath
|
|
if ($unverified) { $script:unverifiedPaths = @($newPath) }
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context $plan
|
|
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') "Preserve mode refuses a path changed between planning and initial read (unverified=$unverified)"
|
|
Assert ($context.Results[0].Diagnostic -match 'changed after planning') 'Stale planned path produces an actionable failure'
|
|
Assert (-not (Test-Path -LiteralPath (Join-Path $context.BackupPath 'before.jsonl'))) 'Path drift is refused before creating a mutation journal entry'
|
|
}
|
|
|
|
Reset-Mocks
|
|
$plan = @((Get-WelaFirewallLoggingPlan)[0])
|
|
$script:onPrompt = { $script:unverifiedPaths = @($script:effectiveProfiles.Domain.LogFileName) }
|
|
$context = New-TestContext -Prompt
|
|
Set-WelaFirewallLoggingControls $context $plan
|
|
Assert ($script:writes -eq 0 -and $context.Results[0].Status -eq 'Failed') 'Newly unknown actual destination permissions prevent a write after confirmation'
|
|
|
|
Reset-Mocks
|
|
$script:unverifiedPaths = @($script:effectiveProfiles.Domain.LogFileName)
|
|
$context = New-TestContext
|
|
Set-WelaFirewallLoggingControls $context @((Get-WelaFirewallLoggingPlan -PathMode CisV4)[0])
|
|
Assert ($script:writes -eq 1 -and $context.Results[0].Status -eq 'Applied') 'Explicit CIS migration can replace an unverified old destination with a verified new destination'
|
|
|
|
Reset-Mocks
|
|
$plan = @(Get-WelaFirewallLoggingPlan)
|
|
$script:blockedAccess = $true
|
|
$context = New-TestContext -DryRun
|
|
Set-WelaFirewallLoggingControls $context $plan
|
|
Assert ($script:writes -eq 0 -and (Complete-WelaConfiguration $context).Failed -eq 3) 'Permission changes after planning fail even a dry run'
|
|
|
|
Reset-Mocks
|
|
$savedOS = $env:OS
|
|
$outputDirectory = Join-Path ([IO.Path]::GetTempPath()) ('wela-firewall-report-' + [guid]::NewGuid().ToString('N'))
|
|
$script:paths += $outputDirectory
|
|
$null = New-Item -ItemType Directory -Path $outputDirectory
|
|
try {
|
|
$env:OS = 'Windows_NT'
|
|
$json = Join-Path $outputDirectory 'plan.json'
|
|
$report = Invoke-WelaFirewallLoggingCommand -Action Plan -ResultsPath $json
|
|
$saved = Get-Content -LiteralPath $json -Raw | ConvertFrom-Json
|
|
Assert ($report.ExitCode -eq 0 -and $saved.Profiles.Count -eq 3 -and $saved.Scope -eq 'firewall-text-logging-only') 'Public plan entrypoint exports all profiles with an explicit scope'
|
|
$dryPath = Join-Path $outputDirectory 'must-not-exist'
|
|
$report = Invoke-WelaFirewallLoggingCommand -Action Configure -DryRun -BackupPath $dryPath -ResultsPath $json
|
|
Assert ($report.DryRun -and $report.ExitCode -eq 0 -and $script:writes -eq 0 -and -not (Test-Path $dryPath)) 'Public configure entrypoint propagates dry run and results scope'
|
|
$rejected = $false
|
|
try { Invoke-WelaFirewallLoggingCommand -Action Audit -DryRun } catch { $rejected = $true }
|
|
Assert $rejected 'Public entrypoint rejects meaningless audit dry-run combinations'
|
|
} finally { $env:OS = $savedOS }
|
|
|
|
# Inspect the only writer's AST: no enforcement/rule parameter or ACL mutation may be hidden in a splat.
|
|
$tokens = $null; $errors = $null
|
|
$ast = [Management.Automation.Language.Parser]::ParseFile((Join-Path $repo 'scripts/FirewallLogging.ps1'), [ref]$tokens, [ref]$errors)
|
|
Assert ($errors.Count -eq 0) 'Firewall helper parses'
|
|
Assert (-not ($ast.Extent.Text -match '(?m)^\s*(Set-Acl|New-NetFirewallRule|Set-NetFirewallRule|Start-Service|Restart-Service)\b')) 'Implementation has no ACL/service/enforcement mutator'
|
|
Write-Host "PASS: $script:assertions firewall text logging assertions (mocked; no Windows policy changes)."
|
|
} finally {
|
|
foreach ($path in $script:paths) { if (Test-Path -LiteralPath $path) { Remove-Item -LiteralPath $path -Recurse -Force } }
|
|
}
|