8787
8888[CmdletBinding ()]
8989param (
90- [ValidateSet (' Disable' , ' Enable' , ' Status' , ' Health' , ' Verify' )]
90+ [ValidateSet (' Disable' , ' Enable' , ' Status' , ' Health' , ' Verify' , ' Manifest ' )]
9191 [string ]$Mode ,
9292
9393 [switch ]$DryRun ,
@@ -121,6 +121,8 @@ Defender Control - CLI Usage
121121 DefenderControl.ps1 -Mode Status -Json Emit state as JSON
122122 DefenderControl.ps1 -Mode Health Extended read-only state enum
123123 DefenderControl.ps1 -Mode Health -Json Extended state as JSON
124+ DefenderControl.ps1 -Mode Manifest Print the latest undo manifest
125+ DefenderControl.ps1 -Mode Manifest -Json Latest manifest as JSON
124126 DefenderControl.ps1 -Help Show this usage
125127
126128Flags:
@@ -481,6 +483,29 @@ function Invoke-CliMode {
481483 exit $script :EXIT_OK
482484 }
483485
486+ ' Manifest' {
487+ $dir = Join-Path $env: ProgramData ' DefenderControl\manifests'
488+ if (-not (Test-Path $dir )) {
489+ Write-CliLine " No manifests directory yet. Run a Disable/Enable first." - ErrorStream
490+ exit $script :EXIT_USAGE
491+ }
492+ $latest = Get-ChildItem - Path $dir - Filter ' *.json' - ErrorAction SilentlyContinue |
493+ Sort-Object LastWriteTime - Descending | Select-Object - First 1
494+ if (-not $latest ) {
495+ Write-CliLine " No manifests found in $dir " - ErrorStream
496+ exit $script :EXIT_USAGE
497+ }
498+ $text = Get-Content - Raw - Path $latest.FullName
499+ if ($Json.IsPresent ) {
500+ [Console ]::Out.WriteLine($text )
501+ } else {
502+ Write-CliLine (" Latest manifest: " + $latest.FullName )
503+ Write-CliLine ' ---'
504+ [Console ]::Out.WriteLine($text )
505+ }
506+ exit $script :EXIT_OK
507+ }
508+
484509 ' Disable' {
485510 Write-CliLine " DefenderControl: -Mode Disable is reserved. Use the GUI for mutating operations." - ErrorStream
486511 exit $script :EXIT_USAGE
@@ -1293,6 +1318,96 @@ function Set-ServicePPL {
12931318 else { Queue-Warn " `$ ServiceName : Could not change LaunchProtected (may need Safe Mode)" }
12941319 return `$ result
12951320}
1321+ function Get-FirewallSnapshot {
1322+ # Returns a plain hashtable of firewall profile state + protected service state.
1323+ # Used as before/after pair to prove this tool did not touch the firewall.
1324+ `$ snap = [ordered]@{}
1325+ try {
1326+ Get-NetFirewallProfile -ErrorAction Stop | ForEach-Object {
1327+ `$ snap["Profile_`$ (`$ _.Name)_Enabled"] = [bool]`$ _.Enabled
1328+ }
1329+ } catch {
1330+ Queue-Verbose " Firewall snapshot: Get-NetFirewallProfile failed (`$ (`$ _.Exception.Message))"
1331+ }
1332+ foreach (`$ svc in @('mpssvc','BFE')) {
1333+ `$ o = Get-Service -Name `$ svc -ErrorAction SilentlyContinue
1334+ if (`$ o) {
1335+ `$ snap["Service_`$ {svc}_Status"] = "`$ (`$ o.Status)"
1336+ `$ snap["Service_`$ {svc}_StartType"] = "`$ (`$ o.StartType)"
1337+ } else {
1338+ `$ snap["Service_`$ {svc}_Status"] = 'NotFound'
1339+ `$ snap["Service_`$ {svc}_StartType"] = 'NotFound'
1340+ }
1341+ }
1342+ return `$ snap
1343+ }
1344+ function Test-FirewallIntact {
1345+ # Before/After are ordered-dict-or-hashtable. Use .Contains for cross-compat.
1346+ param(`$ Before, `$ After)
1347+ `$ diffs = @()
1348+ if (`$ null -eq `$ Before -or `$ null -eq `$ After) { return ,`$ diffs }
1349+ foreach (`$ k in @(`$ Before.Keys)) {
1350+ if (`$ After.Contains(`$ k) -and `$ After[`$ k] -ne `$ Before[`$ k]) {
1351+ `$ diffs += "`$ {k}: `$ (`$ Before[`$ k]) -> `$ (`$ After[`$ k])"
1352+ }
1353+ }
1354+ return ,`$ diffs # force array return
1355+ }
1356+ function Get-ThirdPartyAVList {
1357+ # Query Security Center for registered AV products; exclude Microsoft Defender.
1358+ try {
1359+ `$ products = Get-CimInstance -Namespace 'root\SecurityCenter2' -ClassName 'AntivirusProduct' -ErrorAction Stop
1360+ if (-not `$ products) { return @() }
1361+ return @(`$ products |
1362+ Where-Object { `$ _.displayName -notmatch 'Windows Defender|Microsoft Defender' } |
1363+ ForEach-Object { `$ _.displayName })
1364+ } catch {
1365+ Queue-Verbose " Third-party AV detection failed: `$ (`$ _.Exception.Message)"
1366+ return `$ null
1367+ }
1368+ }
1369+ function New-DefenderControlManifest {
1370+ param([string]`$ Operation, [bool]`$ DryRunFlag)
1371+ `$ dir = Join-Path `$ env:ProgramData 'DefenderControl\manifests'
1372+ if (-not (Test-Path `$ dir)) {
1373+ try { New-Item -Path `$ dir -ItemType Directory -Force -ErrorAction Stop | Out-Null } catch {
1374+ Queue-Verbose " Manifest dir create failed: `$ (`$ _.Exception.Message)"
1375+ return `$ null
1376+ }
1377+ }
1378+ `$ ts = Get-Date -Format 'yyyyMMdd-HHmmss'
1379+ `$ file = Join-Path `$ dir ("`$ Operation-`$ ts.json")
1380+ return [ordered]@{
1381+ schemaVersion = 1
1382+ operation = `$ Operation
1383+ dryRun = `$ DryRunFlag
1384+ startedAt = (Get-Date).ToString('o')
1385+ host = `$ env:COMPUTERNAME
1386+ osBuild = `$ OSBuild
1387+ firewallBefore = `$ null
1388+ firewallAfter = `$ null
1389+ firewallIntact = `$ null
1390+ firewallDiffs = @()
1391+ thirdPartyAV = @()
1392+ phasesCompleted = @()
1393+ finishedAt = `$ null
1394+ path = `$ file
1395+ }
1396+ }
1397+ function Save-DefenderControlManifest {
1398+ param(`$ Manifest)
1399+ if (`$ null -eq `$ Manifest) { return `$ null }
1400+ `$ Manifest.finishedAt = (Get-Date).ToString('o')
1401+ try {
1402+ `$ json = (`$ Manifest | ConvertTo-Json -Depth 8)
1403+ [System.IO.File]::WriteAllText(`$ Manifest.path, `$ json, [System.Text.Encoding]::UTF8)
1404+ Queue-Info " Undo manifest saved: `$ (`$ Manifest.path)"
1405+ return `$ Manifest.path
1406+ } catch {
1407+ Queue-Warn " Manifest save failed: `$ (`$ _.Exception.Message)"
1408+ return `$ null
1409+ }
1410+ }
12961411"@
12971412
12981413# ==================================================================================
@@ -1518,8 +1633,9 @@ function Invoke-DisableDefender {
15181633 $systrayPath = " HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Systray"
15191634 $runPath = " HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
15201635 $explorerPath = " HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"
1521- $totalPhases = 10
1636+ $totalPhases = 11
15221637 $phase = 0
1638+ $manifest = New-DefenderControlManifest - Operation ' Disable' - DryRunFlag $DryRun
15231639
15241640 if ($DryRun ) {
15251641 Queue- Warn " ========== DRY RUN MODE - No changes will be made =========="
@@ -1529,6 +1645,28 @@ function Invoke-DisableDefender {
15291645 Queue- Info " ============================================"
15301646 Start-Sleep - Milliseconds 100
15311647
1648+ # -- Phase 0: Safety pre-flight (firewall snapshot + third-party AV) ----------
1649+ Queue- Phase " --- Phase 0 : Safety Pre-Flight ---"
1650+ $fwBefore = Get-FirewallSnapshot
1651+ if ($manifest ) { $manifest.firewallBefore = $fwBefore }
1652+ Queue- Verbose " Firewall snapshot captured ($ ( $fwBefore.Count ) fields)"
1653+
1654+ $tpAv = Get-ThirdPartyAVList
1655+ if ($manifest ) {
1656+ if ($null -eq $tpAv ) { $manifest.thirdPartyAV = @ () }
1657+ else { $manifest.thirdPartyAV = @ ($tpAv ) }
1658+ }
1659+ if ($null -eq $tpAv ) {
1660+ Queue- Warn " Could not query Security Center for third-party AV"
1661+ } elseif ((@ ($tpAv )).Count -eq 0 ) {
1662+ Queue- Warn " No third-party antivirus detected. After disabling Defender, this system will have"
1663+ Queue- Warn " NO real-time malware protection. Consider installing one (e.g. ESET, Bitdefender,"
1664+ Queue- Warn " Malwarebytes) before rebooting, unless this is an air-gapped / sandbox system."
1665+ } else {
1666+ Queue- Success " Third-party AV detected: $ ( (@ ($tpAv )) -join ' , ' ) "
1667+ }
1668+ Start-Sleep - Milliseconds 60
1669+
15321670 # -- Phase 1: System Restore Point -----------------------------------------------
15331671 $phase ++
15341672 Queue- Status - StatusText " DISABLING..." - StatusColor " #e67e22" - TamperText " " - TamperColor " #7f8c8d" - DisableBtn $false - EnableBtn $false - Progress ([int ]($phase / $totalPhases * 100 )) - RunningText " Phase $phase /$totalPhases - System Restore Point"
@@ -1839,6 +1977,30 @@ function Invoke-DisableDefender {
18391977 } catch { Queue- Info " WinDefend service stop blocked (PPL) - will not restart after reboot" }
18401978 }
18411979
1980+ # -- Phase 11: Firewall Integrity Verification ----------------------------------
1981+ $phase ++
1982+ Queue- Status - StatusText " DISABLING..." - StatusColor " #e67e22" - TamperText " " - TamperColor " #7f8c8d" - DisableBtn $false - EnableBtn $false - Progress ([int ]($phase / $totalPhases * 100 )) - RunningText " Phase $phase /$totalPhases - Firewall integrity check"
1983+ Queue- Phase " --- Phase $phase /$totalPhases : Firewall Integrity Verification ---"
1984+ $fwAfter = Get-FirewallSnapshot
1985+ $fwDiffs = Test-FirewallIntact - Before $fwBefore - After $fwAfter
1986+ if ($manifest ) {
1987+ $manifest.firewallAfter = $fwAfter
1988+ $manifest.firewallDiffs = @ ($fwDiffs )
1989+ $manifest.firewallIntact = ((@ ($fwDiffs )).Count -eq 0 )
1990+ $manifest.phasesCompleted = @ (' Pre-flight' , ' RestorePoint' , ' TamperCheck' , ' Preferences' , ' GroupPolicy' , ' Notifications' , ' ScheduledTasks' , ' Services' , ' ContextMenus' , ' Additional' , ' Processes' , ' FirewallVerify' )
1991+ }
1992+ if ((@ ($fwDiffs )).Count -eq 0 ) {
1993+ Queue- Success " Firewall state unchanged - tool honored the firewall-untouched guarantee"
1994+ } else {
1995+ Queue- Err " Firewall state diverged from pre-flight snapshot!"
1996+ foreach ($d in @ ($fwDiffs )) { Queue- Err " $d " }
1997+ Queue- Warn " This should never happen. Please file an issue with the operation log."
1998+ }
1999+ Start-Sleep - Milliseconds 60
2000+
2001+ # -- Manifest: persist undo/audit manifest --------------------------------------
2002+ $null = Save-DefenderControlManifest - Manifest $manifest
2003+
18422004 # -- Final Status ----------------------------------------------------------------
18432005 Queue- Info " ============================================"
18442006 if ($DryRun ) {
@@ -1878,8 +2040,9 @@ function Invoke-EnableDefender {
18782040 Start-BackgroundWork - AutoRefresh - Work {
18792041 $runPath = " HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run"
18802042 $explorerPath = " HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run"
1881- $totalPhases = 7
2043+ $totalPhases = 8
18822044 $phase = 0
2045+ $manifest = New-DefenderControlManifest - Operation ' Enable' - DryRunFlag $DryRun
18832046
18842047 if ($DryRun ) {
18852048 Queue- Warn " ========== DRY RUN MODE - No changes will be made =========="
@@ -1889,6 +2052,11 @@ function Invoke-EnableDefender {
18892052 Queue- Info " ============================================"
18902053 Start-Sleep - Milliseconds 100
18912054
2055+ # -- Phase 0: Firewall snapshot (for post-enable integrity check) --------------
2056+ $fwBefore = Get-FirewallSnapshot
2057+ if ($manifest ) { $manifest.firewallBefore = $fwBefore }
2058+ Queue- Verbose " Firewall snapshot captured ($ ( $fwBefore.Count ) fields)"
2059+
18922060 # -- Phase 1: Remove Policy Overrides -------------------------------------------
18932061 $phase ++
18942062 Queue- Status - StatusText " ENABLING..." - StatusColor " #e67e22" - TamperText " " - TamperColor " #7f8c8d" - DisableBtn $false - EnableBtn $false - Progress ([int ]($phase / $totalPhases * 100 )) - RunningText " Phase $phase /$totalPhases - Removing policies"
@@ -2154,6 +2322,28 @@ function Invoke-EnableDefender {
21542322 if ($st.RealTimeProtectionEnabled -and $st.AntivirusEnabled ) { $ok = $true }
21552323 } catch { Queue- Warn " Verify failed (reboot needed): $ ( $_.Exception.Message ) " }
21562324
2325+ # -- Phase 8: Firewall Integrity Verification -----------------------------------
2326+ $phase ++
2327+ Queue- Status - StatusText " ENABLING..." - StatusColor " #e67e22" - TamperText " " - TamperColor " #7f8c8d" - DisableBtn $false - EnableBtn $false - Progress ([int ]($phase / $totalPhases * 100 )) - RunningText " Phase $phase /$totalPhases - Firewall integrity check"
2328+ Queue- Phase " --- Phase $phase /$totalPhases : Firewall Integrity Verification ---"
2329+ $fwAfter = Get-FirewallSnapshot
2330+ $fwDiffs = Test-FirewallIntact - Before $fwBefore - After $fwAfter
2331+ if ($manifest ) {
2332+ $manifest.firewallAfter = $fwAfter
2333+ $manifest.firewallDiffs = @ ($fwDiffs )
2334+ $manifest.firewallIntact = ((@ ($fwDiffs )).Count -eq 0 )
2335+ $manifest.phasesCompleted = @ (' FirewallSnapshot' , ' RemovePolicies' , ' RestorePreferences' , ' RestoreServices' , ' ScheduledTasks' , ' ContextMenusSystray' , ' SignatureUpdate' , ' Verify' , ' FirewallVerify' )
2336+ }
2337+ if ((@ ($fwDiffs )).Count -eq 0 ) {
2338+ Queue- Success " Firewall state unchanged - tool honored the firewall-untouched guarantee"
2339+ } else {
2340+ Queue- Err " Firewall state diverged from pre-enable snapshot!"
2341+ foreach ($d in @ ($fwDiffs )) { Queue- Err " $d " }
2342+ }
2343+
2344+ # -- Manifest: persist undo/audit manifest --------------------------------------
2345+ $null = Save-DefenderControlManifest - Manifest $manifest
2346+
21572347 Queue- Info " ============================================"
21582348 if ($DryRun ) {
21592349 Queue- Info " DRY RUN COMPLETE - No changes were made"
0 commit comments