mirror of
https://github.com/Yamato-Security/WELA.git
synced 2026-10-01 20:14:43 +02:00
200 lines
14 KiB
PowerShell
200 lines
14 KiB
PowerShell
# Built-in firewall text logs only. Dot-sourced beside Configuration.ps1 (PowerShell 5.1+).
|
|
function ConvertTo-WelaFirewallLoggingSnapshot {
|
|
param($Profile)
|
|
foreach ($property in @('Name', 'LogAllowed', 'LogBlocked', 'LogMaxSizeKilobytes', 'LogFileName')) {
|
|
if ($null -eq $Profile.$property) { throw "Firewall profile is missing $property." }
|
|
}
|
|
if ([string]$Profile.LogAllowed -notin @('True', 'False', 'NotConfigured') -or
|
|
[string]$Profile.LogBlocked -notin @('True', 'False', 'NotConfigured')) {
|
|
throw 'Unrecognized firewall logging switch value.'
|
|
}
|
|
$size = [uint64]$Profile.LogMaxSizeKilobytes
|
|
[pscustomobject][ordered]@{
|
|
Name = [string]$Profile.Name; LogAllowed = [string]$Profile.LogAllowed
|
|
LogBlocked = [string]$Profile.LogBlocked; LogMaxSizeKilobytes = $size
|
|
LogFileName = [string]$Profile.LogFileName; Enabled = [string]$Profile.Enabled
|
|
}
|
|
}
|
|
|
|
function Test-WelaFirewallServiceAcl {
|
|
param([array]$Rules, [string]$ServiceSid, [switch]$Directory)
|
|
# Group deny ACEs cannot be discounted without the complete service token.
|
|
if (@($Rules | Where-Object AccessControlType -eq Deny).Count) { return $false }
|
|
$required = [int][Security.AccessControl.FileSystemRights]::Modify
|
|
$selfRights = 0; $childRights = 0
|
|
foreach ($rule in $Rules) {
|
|
if ($rule.IdentityReference.Value -ne $ServiceSid -or $rule.AccessControlType -ne 'Allow') { continue }
|
|
if (-not ($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly)) {
|
|
$selfRights = $selfRights -bor [int]$rule.FileSystemRights
|
|
}
|
|
if ($rule.InheritanceFlags -band [Security.AccessControl.InheritanceFlags]::ObjectInherit) {
|
|
$childRights = $childRights -bor [int]$rule.FileSystemRights
|
|
}
|
|
}
|
|
return ($selfRights -band $required) -eq $required -and (-not $Directory -or ($childRights -band $required) -eq $required)
|
|
}
|
|
|
|
function Get-WelaFirewallLogAccess {
|
|
param([string]$Path)
|
|
$result = [pscustomobject]@{ State = 'Unknown'; Path = $Path; Service = 'mpssvc'; ServiceAccount = $null; ServiceStatus = $null; Diagnostic = '' }
|
|
try {
|
|
$expanded = [Environment]::ExpandEnvironmentVariables($Path)
|
|
if ($expanded -notmatch '^[A-Za-z]:\\' -or $expanded -match '%' -or $expanded.Substring(2).Contains(':')) {
|
|
throw 'Log path must resolve to an absolute local drive path without environment placeholders or alternate data streams.'
|
|
}
|
|
$result.Path = $expanded
|
|
$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='mpssvc'" -ErrorAction Stop
|
|
if (-not $service) { throw 'Firewall service could not be read.' }
|
|
$result.ServiceAccount = [string]$service.StartName
|
|
$result.ServiceStatus = [string]$service.State
|
|
if ($service.StartName -notin @('NT AUTHORITY\LocalService', 'NT AUTHORITY\Local Service')) {
|
|
throw 'Firewall service account differs from the documented LocalService configuration; effective token access is unknown.'
|
|
}
|
|
$sidType = (Get-ItemProperty -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Services\mpssvc' -Name ServiceSidType -ErrorAction Stop).ServiceSidType
|
|
if ($sidType -notin @(1, 3)) { throw 'Firewall service SID is not enabled; effective token access is unknown.' }
|
|
$sid = (New-Object Security.Principal.NTAccount('NT SERVICE', 'mpssvc')).Translate([Security.Principal.SecurityIdentifier]).Value
|
|
$parent = Split-Path -Path $expanded -Parent
|
|
if (-not (Test-Path -LiteralPath $parent -PathType Container -ErrorAction Stop)) {
|
|
$result.State = 'Blocked'; throw 'Log directory is missing. Provision its service permissions explicitly before configuring logging.'
|
|
}
|
|
# Never follow a directory junction to an unreviewed destination.
|
|
$ancestor = $parent
|
|
while ($ancestor) {
|
|
$item = Get-Item -LiteralPath $ancestor -Force -ErrorAction Stop
|
|
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) { throw "Reparse point prevents a conservative ACL check: $ancestor" }
|
|
$next = Split-Path -Path $ancestor -Parent
|
|
if ($next -eq $ancestor) { break }
|
|
$ancestor = $next
|
|
}
|
|
$paths = @([pscustomobject]@{ Path = $parent; Directory = $true })
|
|
if (Test-Path -LiteralPath $expanded -ErrorAction Stop) {
|
|
$file = Get-Item -LiteralPath $expanded -Force -ErrorAction Stop
|
|
if ($file.PSIsContainer -or ($file.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Log target is a directory or reparse point.' }
|
|
$paths += [pscustomobject]@{ Path = $expanded; Directory = $false }
|
|
}
|
|
foreach ($target in $paths) {
|
|
$acl = Get-Acl -LiteralPath $target.Path -ErrorAction Stop
|
|
$rules = @($acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier]))
|
|
if (-not (Test-WelaFirewallServiceAcl -Rules $rules -ServiceSid $sid -Directory:$target.Directory)) {
|
|
throw "No unambiguous mpssvc Modify grant at $($target.Path), including directory file inheritance. Deny ACEs or group-based access require effective-token review."
|
|
}
|
|
}
|
|
if ($service.State -ne 'Running') { $result.State = 'Blocked'; throw 'Firewall service is not running. WELA will not start it or change firewall enforcement.' }
|
|
$result.State = 'VerifiedExplicitGrant'
|
|
$result.Diagnostic = 'Static ACL check found mpssvc Modify rights on the directory and existing file, including file inheritance. Actual log creation and rotation still require traffic validation.'
|
|
} catch { $result.Diagnostic = $_.Exception.Message }
|
|
return $result
|
|
}
|
|
|
|
function Get-WelaFirewallLoggingState {
|
|
param([ValidateSet('Domain', 'Private', 'Public')][string]$Name)
|
|
$effectiveProfiles = @(Get-NetFirewallProfile -Name $Name -PolicyStore ActiveStore -ErrorAction Stop)
|
|
$localProfiles = @(Get-NetFirewallProfile -Name $Name -PolicyStore PersistentStore -ErrorAction Stop)
|
|
if ($effectiveProfiles.Count -ne 1 -or $localProfiles.Count -ne 1 -or $effectiveProfiles[0].Name -ne $Name -or $localProfiles[0].Name -ne $Name) {
|
|
throw "Expected exactly one $Name profile from each firewall policy store."
|
|
}
|
|
$effective = ConvertTo-WelaFirewallLoggingSnapshot $effectiveProfiles[0]
|
|
$local = ConvertTo-WelaFirewallLoggingSnapshot $localProfiles[0]
|
|
$differences = @('LogAllowed', 'LogBlocked', 'LogMaxSizeKilobytes', 'LogFileName' | Where-Object { $effective.$_ -ne $local.$_ })
|
|
[pscustomobject]@{
|
|
Effective = $effective; Local = $local; DifferentFromLocal = $differences
|
|
PolicySource = 'ActiveStore is resultant policy; PersistentStore is local policy. Differences can reflect GPO/MDM. The current policy writer and future persistence are not established.'
|
|
Access = Get-WelaFirewallLogAccess -Path $effective.LogFileName
|
|
}
|
|
}
|
|
|
|
function Get-WelaFirewallLoggingPlan {
|
|
param([ValidateSet('Preserve', 'CisV4')][string]$PathMode = 'Preserve',
|
|
[ValidateRange(16384, 32767)][int]$MinimumSizeKiB = 16384)
|
|
foreach ($name in @('Domain', 'Private', 'Public')) {
|
|
$before = $null; $desired = $null
|
|
try {
|
|
$before = Get-WelaFirewallLoggingState -Name $name
|
|
$path = if ($PathMode -eq 'CisV4') { '%SystemRoot%\System32\LogFiles\Firewall\' + $name.ToLowerInvariant() + 'fw.log' } else { $before.Effective.LogFileName }
|
|
$desired = [pscustomobject]@{ LogAllowed = 'True'; LogBlocked = 'True'; MinimumSizeKiB = $MinimumSizeKiB; LogFileName = $path; PathMode = $PathMode }
|
|
$access = if ($path -eq $before.Effective.LogFileName) { $before.Access } else { Get-WelaFirewallLogAccess -Path $path }
|
|
$status = if ($access.State -ne 'VerifiedExplicitGrant') { $access.State }
|
|
elseif (Test-WelaFirewallLoggingCompliance -Snapshot $before -Desired $desired) { 'Compliant' } else { 'ChangeRequired' }
|
|
[pscustomobject]@{ Name = $name; Status = $status; Before = $before; Desired = $desired; TargetAccess = $access; Diagnostic = $access.Diagnostic }
|
|
} catch {
|
|
[pscustomobject]@{ Name = $name; Status = 'Unknown'; Before = $before; Desired = $desired; TargetAccess = $null; Diagnostic = $_.Exception.Message }
|
|
}
|
|
}
|
|
}
|
|
|
|
function Test-WelaFirewallLoggingCompliance {
|
|
param($Snapshot, $Desired)
|
|
$pathMatches = [Environment]::ExpandEnvironmentVariables($Snapshot.Effective.LogFileName) -eq [Environment]::ExpandEnvironmentVariables($Desired.LogFileName)
|
|
return $Snapshot.Access.State -eq 'VerifiedExplicitGrant' -and $Snapshot.Effective.LogAllowed -eq 'True' -and
|
|
$Snapshot.Effective.LogBlocked -eq 'True' -and $Snapshot.Effective.LogMaxSizeKilobytes -ge $Desired.MinimumSizeKiB -and $pathMatches
|
|
}
|
|
|
|
function Set-WelaFirewallLoggingControls {
|
|
param($Context, [array]$Plan)
|
|
foreach ($entry in $Plan) {
|
|
$id = "FirewallTextLog/$($entry.Name)"
|
|
if ($entry.Status -in @('Unknown', 'Blocked')) {
|
|
$Context.Results.Add([pscustomobject]@{ Id = $id; Kind = 'FirewallTextLog'; Target = $entry.Name; Desired = $entry.Desired; Before = $entry.Before; After = $null; Status = 'Failed'; Diagnostic = "Logging prerequisite $($entry.Status): $($entry.Diagnostic)" })
|
|
continue
|
|
}
|
|
$callback = @{ Name = $entry.Name; Desired = $entry.Desired; Observed = $null }
|
|
$read = {
|
|
param($state)
|
|
$snapshot = Get-WelaFirewallLoggingState -Name $state.Name
|
|
if ($state.Desired.PathMode -eq 'Preserve' -and
|
|
[Environment]::ExpandEnvironmentVariables($snapshot.Effective.LogFileName) -ne [Environment]::ExpandEnvironmentVariables($state.Desired.LogFileName)) {
|
|
throw 'Effective firewall log path changed after planning; rerun the plan to assess and preserve the current destination.'
|
|
}
|
|
$targetAccess = if ($state.Desired.PathMode -eq 'Preserve' -or $snapshot.Effective.LogFileName -eq $state.Desired.LogFileName) {
|
|
$snapshot.Access
|
|
} else { Get-WelaFirewallLogAccess -Path $state.Desired.LogFileName }
|
|
if ($targetAccess.State -ne 'VerifiedExplicitGrant') { throw "Log path access is $($targetAccess.State): $($targetAccess.Diagnostic)" }
|
|
$state.Observed = $snapshot
|
|
return $snapshot
|
|
}
|
|
$test = { param($snapshot, $state) Test-WelaFirewallLoggingCompliance -Snapshot $snapshot -Desired $state.Desired }
|
|
$apply = {
|
|
param($state)
|
|
# Refuse races after a prompt/journal rather than overwrite an operator's changes.
|
|
$fresh = Get-WelaFirewallLoggingState -Name $state.Name
|
|
foreach ($store in @('Effective', 'Local')) {
|
|
foreach ($property in @('LogAllowed', 'LogBlocked', 'LogMaxSizeKilobytes', 'LogFileName')) {
|
|
if ($fresh.$store.$property -ne $state.Observed.$store.$property) { throw "Firewall $store $property changed after the recovery snapshot; retry after reviewing policy." }
|
|
}
|
|
}
|
|
if ($state.Desired.PathMode -eq 'Preserve' -and $fresh.Access.State -ne 'VerifiedExplicitGrant') {
|
|
throw "Effective log path access is $($fresh.Access.State): $($fresh.Access.Diagnostic)"
|
|
}
|
|
$targetPath = if ($state.Desired.PathMode -eq 'Preserve') { $fresh.Effective.LogFileName } else { $state.Desired.LogFileName }
|
|
$access = Get-WelaFirewallLogAccess -Path $targetPath
|
|
if ($access.State -ne 'VerifiedExplicitGrant') { throw "Log path access is $($access.State): $($access.Diagnostic)" }
|
|
$size = [Math]::Max([double]$state.Desired.MinimumSizeKiB, [Math]::Max([double]$fresh.Effective.LogMaxSizeKilobytes, [double]$fresh.Local.LogMaxSizeKilobytes))
|
|
$parameters = @{ Name = $state.Name; PolicyStore = 'PersistentStore'; LogAllowed = 'True'; LogBlocked = 'True'; LogMaxSizeKilobytes = [uint64]$size; ErrorAction = 'Stop' }
|
|
if ($state.Desired.PathMode -eq 'CisV4') { $parameters.LogFileName = $state.Desired.LogFileName }
|
|
Set-NetFirewallProfile @parameters
|
|
'Local logging settings were written. Effective ActiveStore read-back follows; GPO/MDM may override local values now or later.'
|
|
}
|
|
Invoke-WelaConfigurationControl -Context $Context -Id $id -Kind FirewallTextLog -Target @{ Name = $entry.Name; PolicyStore = 'PersistentStore' } `
|
|
-Desired $entry.Desired -Read $read -Compliant $test -Apply $apply -CallbackState $callback `
|
|
-Description 'Enable allowed/dropped text logging and its minimum size; preserve enforcement and rules.'
|
|
}
|
|
}
|
|
|
|
function Invoke-WelaFirewallLoggingCommand {
|
|
param([ValidateSet('Audit', 'Plan', 'Configure')][string]$Action = 'Audit',
|
|
[ValidateSet('Preserve', 'CisV4')][string]$PathMode = 'Preserve',
|
|
[ValidateRange(16384, 32767)][int]$MinimumSizeKiB = 16384,
|
|
[switch]$Auto, [switch]$DryRun, [string]$BackupPath, [string]$ResultsPath)
|
|
if ($env:OS -ne 'Windows_NT') { throw 'Firewall text logging requires Windows and the NetSecurity module.' }
|
|
if ($DryRun -and $Action -ne 'Configure') { throw '-DryRun applies only to FirewallAction Configure; Audit and Plan are read-only.' }
|
|
$plan = @(Get-WelaFirewallLoggingPlan -PathMode $PathMode -MinimumSizeKiB $MinimumSizeKiB)
|
|
if ($Action -eq 'Configure') {
|
|
$context = New-WelaConfigurationContext -Auto:$Auto -DryRun:$DryRun -BackupPath $BackupPath
|
|
Set-WelaFirewallLoggingControls -Context $context -Plan $plan
|
|
return Complete-WelaConfiguration -Context $context -ResultsPath $ResultsPath -Scope 'firewall-text-logging-only'
|
|
}
|
|
$report = [pscustomobject]@{ Scope = 'firewall-text-logging-only'; Action = $Action; PathMode = $PathMode; MinimumSizeKiB = $MinimumSizeKiB; Profiles = $plan; ExitCode = $(if (@($plan | Where-Object Status -in @('Unknown', 'Blocked')).Count) { 1 } else { 0 }) }
|
|
if ($ResultsPath) { $report | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $ResultsPath -Encoding UTF8 -ErrorAction Stop }
|
|
return $report
|
|
}
|