Hardening/Invoke-SecurityRemediation.ps1
|
function Invoke-SecurityRemediation { <# .SYNOPSIS Applies automated remediations for findings produced by Invoke-SecurityAssessment .DESCRIPTION Closes the loop from finding to fix: consumes a saved assessment JSON file (or a piped assessment/findings object), filters findings by severity, matches each finding against a curated map of safe, automatable remediations, and applies the fix with SupportsShouldProcess (-WhatIf/-Confirm). Findings with no matching remediation are reported as ManualReview and are never improvised. Optionally writes a rollback script capturing pre-change state. .PARAMETER AssessmentPath Path to a JSON file previously saved by Invoke-SecurityAssessment .PARAMETER InputObject Assessment object (or findings array / single finding) accepted from the pipeline .PARAMETER MaxSeverity Only remediate findings at or above this severity: Critical, High, Medium, or Low (default: High) .PARAMETER GenerateRollback Write a rollback .ps1 script that captures current values BEFORE they are changed .PARAMETER RollbackDirectory Directory where the rollback script is written (default: current location) .EXAMPLE Invoke-SecurityRemediation -AssessmentPath "C:\Assessments\SecurityAssessment.json" -WhatIf Preview every remediation that would be applied without changing any state .EXAMPLE Invoke-SecurityRemediation -AssessmentPath "C:\Assessments\SecurityAssessment.json" -MaxSeverity Critical -GenerateRollback Remediate only Critical findings and write a rollback script with pre-change values .EXAMPLE Invoke-SecurityAssessment -OutputPath "C:\Assessments" | Invoke-SecurityRemediation -GenerateRollback -Confirm Pipe an assessment object directly into remediation with confirmation prompts .OUTPUTS PSCustomObject with FindingsProcessed, Remediated, ManualReview, Failed, and RollbackPath #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High', DefaultParameterSetName = 'File')] param( [Parameter(Mandatory, ParameterSetName = 'File', Position = 0)] [ValidateScript({ Test-Path -Path $_ -PathType Leaf })] [string]$AssessmentPath, [Parameter(Mandatory, ParameterSetName = 'Object', ValueFromPipeline)] [AllowNull()] [object]$InputObject, [Parameter()] [ValidateSet('Critical', 'High', 'Medium', 'Low')] [string]$MaxSeverity = 'High', [Parameter()] [switch]$GenerateRollback, [Parameter()] [string]$RollbackDirectory = (Get-Location).Path ) begin { Write-Host "=== AUTOMATED SECURITY REMEDIATION ===" -ForegroundColor Cyan Write-Host "Max Severity: $MaxSeverity" -ForegroundColor Yellow Write-Host "Start Time: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor Yellow if ($WhatIfPreference) { Write-Host "MODE: WhatIf (no changes will be made)" -ForegroundColor Magenta } # Check for admin privileges $isAdmin = $false try { $currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() $isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { Write-Warning "Could not determine elevation status on this platform; remediation cmdlets may fail without Administrator privileges." $isAdmin = $true } if (-not $isAdmin) { Write-Error "Invoke-SecurityRemediation requires Administrator privileges. Re-run from an elevated PowerShell session." throw "This function requires Administrator privileges!" } $severityRank = @{ Critical = 4 High = 3 Medium = 2 Low = 1 } $threshold = $severityRank[$MaxSeverity] $collectedFindings = @() $results = [PSCustomObject]@{ RemediationDate = Get-Date -Format "yyyy-MM-dd HH:mm:ss" MaxSeverity = $MaxSeverity WhatIf = [bool]$WhatIfPreference FindingsProcessed = 0 SkippedBelowThreshold = 0 Remediated = @() ManualReview = @() Failed = @() RollbackPath = $null } $rollbackScriptLines = @() # Remediation map: curated, safe, automatable fixes. Anything unmatched -> ManualReview. $uacRegistryPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System" $psLoggingPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging" $rdpTcpPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Terminal Server\WinStations\RDP-Tcp" $remediationMap = @( [PSCustomObject]@{ Name = 'Enable Windows Firewall profiles' FindingPattern = 'firewall' RequiresAdmin = $true Action = { Set-NetFirewallProfile -Profile Domain, Public, Private -Enabled True -ErrorAction Stop } Verify = { $disabled = @(Get-NetFirewallProfile -ErrorAction Stop | Where-Object { -not $_.Enabled }) return (@($disabled).Count -eq 0) } Rollback = { $lines = @() $fwProfiles = @(Get-NetFirewallProfile -ErrorAction Stop | Select-Object Name, Enabled) foreach ($fwProfile in $fwProfiles) { $lines += "Set-NetFirewallProfile -Profile '$($fwProfile.Name)' -Enabled `$$($fwProfile.Enabled)" } return $lines } } [PSCustomObject]@{ Name = 'Enable User Account Control (UAC)' FindingPattern = 'user account control|\buac\b' RequiresAdmin = $true Action = { Set-ItemProperty -Path $uacRegistryPath -Name "EnableLUA" -Value 1 -Type DWord -Force -ErrorAction Stop } Verify = { $current = Get-ItemProperty -Path $uacRegistryPath -Name "EnableLUA" -ErrorAction Stop return ($current.EnableLUA -eq 1) } Rollback = { $previous = 0 try { $current = Get-ItemProperty -Path $uacRegistryPath -Name "EnableLUA" -ErrorAction Stop if ($null -ne $current.EnableLUA) { $previous = $current.EnableLUA } } catch { $previous = 'NOT_PRESENT' } if ($previous -eq 'NOT_PRESENT') { return @("Remove-ItemProperty -Path '$uacRegistryPath' -Name 'EnableLUA' -ErrorAction SilentlyContinue") } return @("Set-ItemProperty -Path '$uacRegistryPath' -Name 'EnableLUA' -Value $previous -Type DWord -Force") } } [PSCustomObject]@{ Name = 'Enable Defender real-time protection' FindingPattern = 'real-?time protection|realtimemonitoring' RequiresAdmin = $true Action = { Set-MpPreference -DisableRealtimeMonitoring $false -ErrorAction Stop } Verify = { $prefs = Get-MpPreference -ErrorAction Stop return (-not $prefs.DisableRealtimeMonitoring) } Rollback = { $previous = $false try { $prefs = Get-MpPreference -ErrorAction Stop if ($null -ne $prefs.DisableRealtimeMonitoring) { $previous = [bool]$prefs.DisableRealtimeMonitoring } } catch { $previous = $false } return @("Set-MpPreference -DisableRealtimeMonitoring `$$previous") } } [PSCustomObject]@{ Name = 'Disable SMBv1 protocol' FindingPattern = 'smb\s?v1|smb version 1|smb 1\.0' RequiresAdmin = $true Action = { Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force -ErrorAction Stop } Verify = { $config = Get-SmbServerConfiguration -ErrorAction Stop return (-not $config.EnableSMB1Protocol) } Rollback = { $previous = $true try { $config = Get-SmbServerConfiguration -ErrorAction Stop if ($null -ne $config.EnableSMB1Protocol) { $previous = [bool]$config.EnableSMB1Protocol } } catch { $previous = $true } return @("Set-SmbServerConfiguration -EnableSMB1Protocol `$$previous -Force") } } [PSCustomObject]@{ Name = 'Disable Guest account' FindingPattern = 'guest account|guest user' RequiresAdmin = $true Action = { # Look up the built-in Guest account by well-known SID RID 501 (culture-invariant) $guestAccount = @(Get-LocalUser -ErrorAction Stop | Where-Object { $null -ne $_.SID -and $_.SID.Value -match '-501$' }) | Select-Object -First 1 if ($null -eq $guestAccount) { throw "Built-in Guest account (RID 501) was not found on this system." } Disable-LocalUser -SID $guestAccount.SID -ErrorAction Stop } Verify = { $guest = @(Get-LocalUser -ErrorAction Stop | Where-Object { $null -ne $_.SID -and $_.SID.Value -match '-501$' }) | Select-Object -First 1 return ($null -ne $guest -and -not $guest.Enabled) } Rollback = { $wasEnabled = $false $guestSid = $null try { $guest = @(Get-LocalUser -ErrorAction Stop | Where-Object { $null -ne $_.SID -and $_.SID.Value -match '-501$' }) | Select-Object -First 1 if ($null -ne $guest) { $wasEnabled = [bool]$guest.Enabled $guestSid = $guest.SID.Value } } catch { $wasEnabled = $false } if ($wasEnabled -and $guestSid) { return @("Enable-LocalUser -SID '$guestSid'") } return @("# Guest account was already disabled; no rollback action required") } } [PSCustomObject]@{ Name = 'Enable PowerShell script block logging' FindingPattern = 'script block logging|scriptblocklogging|powershell logging' RequiresAdmin = $true Action = { if (-not (Test-Path $psLoggingPath)) { New-Item -Path $psLoggingPath -Force -ErrorAction Stop | Out-Null } Set-ItemProperty -Path $psLoggingPath -Name "EnableScriptBlockLogging" -Value 1 -Type DWord -Force -ErrorAction Stop } Verify = { $current = Get-ItemProperty -Path $psLoggingPath -Name "EnableScriptBlockLogging" -ErrorAction Stop return ($current.EnableScriptBlockLogging -eq 1) } Rollback = { $previous = 0 $existed = $false try { $current = Get-ItemProperty -Path $psLoggingPath -Name "EnableScriptBlockLogging" -ErrorAction Stop if ($null -ne $current.EnableScriptBlockLogging) { $previous = $current.EnableScriptBlockLogging $existed = $true } } catch { $existed = $false } if ($existed) { return @("Set-ItemProperty -Path '$psLoggingPath' -Name 'EnableScriptBlockLogging' -Value $previous -Type DWord -Force") } return @("Remove-ItemProperty -Path '$psLoggingPath' -Name 'EnableScriptBlockLogging' -ErrorAction SilentlyContinue") } } [PSCustomObject]@{ Name = 'Enable RDP Network Level Authentication' FindingPattern = 'network level authentication|\bnla\b' RequiresAdmin = $true Action = { Set-ItemProperty -Path $rdpTcpPath -Name "UserAuthentication" -Value 1 -Type DWord -Force -ErrorAction Stop } Verify = { $current = Get-ItemProperty -Path $rdpTcpPath -Name "UserAuthentication" -ErrorAction Stop return ($current.UserAuthentication -eq 1) } Rollback = { $previous = 0 try { $current = Get-ItemProperty -Path $rdpTcpPath -Name "UserAuthentication" -ErrorAction Stop if ($null -ne $current.UserAuthentication) { $previous = $current.UserAuthentication } } catch { $previous = 0 } return @("Set-ItemProperty -Path '$rdpTcpPath' -Name 'UserAuthentication' -Value $previous -Type DWord -Force") } } ) } process { if ($PSCmdlet.ParameterSetName -eq 'Object') { if ($null -ne $InputObject) { $collectedFindings += $InputObject } } } end { try { # --- Gather findings --- $rawInput = @() if ($PSCmdlet.ParameterSetName -eq 'File') { Write-Host "`n[LOAD] Reading assessment from: $AssessmentPath" -ForegroundColor Cyan try { $rawInput = @(Get-Content -Path $AssessmentPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop) } catch { Write-Error "Failed to read or parse assessment file '$AssessmentPath': $_" throw } } else { $rawInput = $collectedFindings } # Unwrap: an assessment object carries a Findings array; a bare finding carries Finding text $allFindings = @() foreach ($item in $rawInput) { if ($null -eq $item) { continue } if ($null -ne $item.PSObject.Properties['Findings'] -and $null -ne $item.Findings) { $allFindings += @($item.Findings) } elseif ($null -ne $item.PSObject.Properties['Finding']) { $allFindings += $item } elseif ($item -is [System.Collections.IEnumerable] -and $item -isnot [string] -and $item -isnot [hashtable]) { # A bare findings array bound directly to -InputObject (pipeline input arrives item-by-item) $allFindings += @($item) } } $results.FindingsProcessed = @($allFindings).Count Write-Host "[LOAD] Findings loaded: $($results.FindingsProcessed)" -ForegroundColor Yellow # --- Per-finding remediation --- $findingIndex = 0 foreach ($finding in $allFindings) { $findingIndex++ $findingText = '' if ($null -ne $finding.PSObject.Properties['Finding'] -and $null -ne $finding.Finding) { $findingText = [string]$finding.Finding } $severityText = '' if ($null -ne $finding.PSObject.Properties['Severity'] -and $null -ne $finding.Severity) { $severityText = [string]$finding.Severity } # Severity gate: findings with unrecognized severity are never auto-remediated if ([string]::IsNullOrWhiteSpace($severityText) -or -not $severityRank.ContainsKey($severityText)) { Write-Host "`n[$findingIndex] Unrecognized severity - routed to manual review: $findingText" -ForegroundColor Yellow $results.ManualReview += [PSCustomObject]@{ Finding = $findingText Severity = $severityText Reason = 'Severity missing or unrecognized; not eligible for automated remediation' } continue } if ($severityRank[$severityText] -lt $threshold) { $results.SkippedBelowThreshold++ Write-Verbose "[$findingIndex] Skipped (severity $severityText below threshold $MaxSeverity): $findingText" continue } # Match against the curated remediation map - first match wins $matched = $null if (-not [string]::IsNullOrWhiteSpace($findingText)) { foreach ($entry in $remediationMap) { if ($findingText -match $entry.FindingPattern) { $matched = $entry break } } } if ($null -eq $matched) { Write-Host "`n[$findingIndex] No automated remediation available: $findingText" -ForegroundColor Yellow $results.ManualReview += [PSCustomObject]@{ Finding = $findingText Severity = $severityText Reason = 'No matching remediation in the curated map; manual review required' } continue } Write-Host "`n[$findingIndex] [$severityText] $findingText" -ForegroundColor Cyan Write-Host " Remediation: $($matched.Name) (RequiresAdmin: $($matched.RequiresAdmin))" -ForegroundColor White if (-not $PSCmdlet.ShouldProcess($findingText, "Apply remediation '$($matched.Name)'")) { Write-Host " WhatIf: would apply '$($matched.Name)'" -ForegroundColor Magenta continue } # Capture pre-change state BEFORE applying the fix (for the rollback script) $capturedRollback = @() if ($GenerateRollback) { try { $capturedRollback = @(& $matched.Rollback) } catch { Write-Warning " Could not capture pre-change state for rollback: $_" $capturedRollback = @("# WARNING: pre-change state could not be captured: $($_.Exception.Message)") } } try { & $matched.Action # Verify-after-fix where cheap (re-read the setting) $verified = $false try { $verified = [bool](& $matched.Verify) } catch { Write-Verbose " Verification check failed: $_" $verified = $false } $results.Remediated += [PSCustomObject]@{ Finding = $findingText Severity = $severityText Remediation = $matched.Name Verified = $verified } if ($verified) { Write-Host " + Remediated and verified" -ForegroundColor Green } else { Write-Host " + Remediated (verification inconclusive)" -ForegroundColor Yellow } if ($GenerateRollback -and @($capturedRollback).Count -gt 0) { # Finding text comes from a JSON file / pipeline input; a newline # would break out of the comment and inject lines into the # elevated rollback script, so collapse line breaks $rollbackFindingText = $findingText -replace '[\r\n]+', ' ' $rollbackScriptLines += "# Rollback for: $rollbackFindingText" $rollbackScriptLines += "# Remediation applied: $($matched.Name) on $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" $rollbackScriptLines += $capturedRollback $rollbackScriptLines += "" } } catch { $results.Failed += [PSCustomObject]@{ Finding = $findingText Severity = $severityText Remediation = $matched.Name Error = $_.Exception.Message } Write-Warning " Remediation failed: $($_.Exception.Message)" } } # --- Write rollback script (captured pre-change values only) --- if ($GenerateRollback -and @($rollbackScriptLines).Count -gt 0) { try { $rollbackFile = Join-Path -Path $RollbackDirectory -ChildPath "SecurityRemediation_Rollback_$(Get-Date -Format 'yyyyMMdd_HHmmss').ps1" $header = @( "#Requires -RunAsAdministrator" "# Rollback script generated by Invoke-SecurityRemediation" "# Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" "# Restores the pre-remediation state captured BEFORE changes were applied" "" ) if ($PSCmdlet.ShouldProcess($rollbackFile, "Write rollback script")) { if (-not (Test-Path -Path $RollbackDirectory)) { New-Item -Path $RollbackDirectory -ItemType Directory -Force | Out-Null } Set-Content -Path $rollbackFile -Value (($header + $rollbackScriptLines) -join [Environment]::NewLine) -Encoding UTF8 -ErrorAction Stop $results.RollbackPath = $rollbackFile Write-Host "`n[ROLLBACK] Rollback script written: $rollbackFile" -ForegroundColor Green } else { Write-Host "`n[ROLLBACK] WhatIf: would write rollback script to $rollbackFile" -ForegroundColor Magenta } } catch { Write-Warning "Failed to write rollback script: $_" } } elseif ($GenerateRollback) { Write-Host "`n[ROLLBACK] No changes were applied; rollback script not needed" -ForegroundColor Yellow } } catch { Write-Error "Error during security remediation: $_" throw } Write-Host "`n=== REMEDIATION COMPLETE ===" -ForegroundColor Green Write-Host "`nFindings Processed: $($results.FindingsProcessed)" -ForegroundColor Cyan Write-Host " Remediated: $(@($results.Remediated).Count)" -ForegroundColor Green Write-Host " Manual Review: $(@($results.ManualReview).Count)" -ForegroundColor Yellow Write-Host " Failed: $(@($results.Failed).Count)" -ForegroundColor Red Write-Host " Skipped (below $MaxSeverity): $($results.SkippedBelowThreshold)" -ForegroundColor White if (@($results.ManualReview).Count -gt 0) { Write-Host "`nFindings requiring manual review:" -ForegroundColor Yellow $results.ManualReview | ForEach-Object { Write-Host " - [$($_.Severity)] $($_.Finding)" -ForegroundColor White } } if (@($results.Failed).Count -gt 0) { Write-Host "`nFailed remediations:" -ForegroundColor Red $results.Failed | ForEach-Object { Write-Host " - $($_.Finding): $($_.Error)" -ForegroundColor Red } } if ($null -ne $results.RollbackPath) { Write-Host "`nRollback script: $($results.RollbackPath)" -ForegroundColor Cyan Write-Host " Run it from an elevated session to restore pre-remediation state" -ForegroundColor White } Write-Host "`nRecommendations:" -ForegroundColor Cyan Write-Host " 1. Review the ManualReview findings - no automated fix was attempted for them" -ForegroundColor White Write-Host " 2. Test system and application functionality after remediation" -ForegroundColor White Write-Host " 3. Keep the rollback script until changes are validated" -ForegroundColor White Write-Host " 4. Re-run Invoke-SecurityAssessment to confirm findings are resolved" -ForegroundColor White Write-Host " 5. Some changes (e.g. SMBv1) may require a reboot to take full effect" -ForegroundColor White return $results } } |