@@ -90,12 +90,17 @@ param(
9090 [ValidateSet (' Disable' , ' Enable' , ' Status' , ' Health' , ' Verify' , ' Manifest' )]
9191 [string ]$Mode ,
9292
93+ [ValidateSet (' Enabled' , ' Disabled' , ' Auto' )]
94+ [string ]$Expect = ' Auto' ,
95+
9396 [switch ]$DryRun ,
9497 [switch ]$Silent ,
9598 [switch ]$Json ,
9699 [switch ]$Help ,
97100 [switch ]$NoRestorePoint ,
98- [switch ]$NoReboot
101+ [switch ]$NoReboot ,
102+ [switch ]$Eicar ,
103+ [switch ]$Force
99104)
100105
101106# ==================================================================================
@@ -106,6 +111,7 @@ $script:EXIT_PARTIAL = 1
106111$script :EXIT_TAMPER_BLOCKED = 2
107112$script :EXIT_SAFEMODE = 3
108113$script :EXIT_USAGE = 4
114+ $script :EXIT_VERIFY_FAIL = 5
109115
110116$script :IsCliMode = [bool ]$Mode -or $Help.IsPresent
111117
@@ -123,16 +129,27 @@ Defender Control - CLI Usage
123129 DefenderControl.ps1 -Mode Health -Json Extended state as JSON
124130 DefenderControl.ps1 -Mode Manifest Print the latest undo manifest
125131 DefenderControl.ps1 -Mode Manifest -Json Latest manifest as JSON
132+ DefenderControl.ps1 -Mode Verify Assert Defender state matches
133+ an expected shape (auto-inferred)
134+ DefenderControl.ps1 -Mode Verify -Expect Enabled Assert fully enabled
135+ DefenderControl.ps1 -Mode Verify -Expect Disabled Assert fully disabled
136+ DefenderControl.ps1 -Mode Verify -Eicar -Force Include EICAR detection test
137+ (writes + cleans a harmless
138+ AV-signature test file)
126139 DefenderControl.ps1 -Help Show this usage
127140
128141Flags:
129142 -Silent Suppress non-essential CLI output
130143 -DryRun Simulate without applying (GUI + CLI)
131144 -NoRestorePoint Skip restore-point creation (CLI-only)
132145 -NoReboot Suppress reboot prompt (CLI-only)
146+ -Expect Verify assertion target: Enabled | Disabled | Auto (default)
147+ -Eicar Opt-in EICAR synthetic detection test (Verify-only)
148+ -Force Required with -Eicar to actually write the test file
133149
134150Exit codes:
135- 0 success 1 partial 2 tamper-blocked 3 safe-mode-needed 4 usage-error
151+ 0 success 1 partial 2 tamper-blocked 3 safe-mode-needed
152+ 4 usage-error 5 verify-fail
136153
137154Note: -Mode Disable|Enable are reserved; use the GUI for mutating operations.
138155'@
@@ -407,18 +424,146 @@ function Write-CliLine {
407424 else { [Console ]::Out.WriteLine($Text ) }
408425}
409426
427+ function Invoke-VerifyMode {
428+ param (
429+ [string ]$Expect = ' Auto' ,
430+ [switch ]$Json ,
431+ [switch ]$Eicar ,
432+ [switch ]$Force
433+ )
434+
435+ $state = Get-DefenderState - Extended
436+
437+ # Auto: infer expectation from current effective state
438+ $expectResolved = $Expect
439+ if ($expectResolved -eq ' Auto' ) {
440+ $expectResolved = if ($state.DefenderEffectivelyEnabled ) { ' Enabled' } else { ' Disabled' }
441+ }
442+
443+ $checks = New-Object System.Collections.Generic.List[hashtable ]
444+ function _check ([string ]$name , $expected , $actual ) {
445+ $result = if ($null -eq $expected -or $expected -eq $actual ) { ' PASS' } else { ' FAIL' }
446+ $checks.Add ([ordered ]@ {
447+ name = $name
448+ expected = $expected
449+ actual = $actual
450+ result = $result
451+ }) | Out-Null
452+ }
453+
454+ if ($expectResolved -eq ' Enabled' ) {
455+ _check ' RealTimeProtectionEnabled' $true $state.RealTimeProtectionEnabled
456+ _check ' AntivirusEnabled' $true $state.AntivirusEnabled
457+ _check ' AntispywareEnabled' $true $state.AntispywareEnabled
458+ _check ' WinDefendRunning' ' Running' $state.WinDefendStatus
459+ _check ' WinDefendNotDisabled' $true ($state.WinDefendStartType -ne ' Disabled' )
460+ _check ' NoGroupPolicyDisable' $true ($state.PolicyDisableAntiSpyware -ne 1 )
461+ } else {
462+ # 'Disabled' expectation — any of these signals successful disable
463+ $anyDisabled = (
464+ $state.RealTimeProtectionEnabled -eq $false -or
465+ $state.AntivirusEnabled -eq $false -or
466+ $state.WinDefendStatus -eq ' Stopped' -or
467+ $state.WinDefendStartType -eq ' Disabled' -or
468+ $state.PolicyDisableAntiSpyware -eq 1
469+ )
470+ _check ' DefenderEffectivelyDisabled' $true $anyDisabled
471+ _check ' WinDefendServiceStatus' $null $state.WinDefendStatus
472+ _check ' WinDefendStartType' $null $state.WinDefendStartType
473+ _check ' PolicyDisableAntiSpyware' $null $state.PolicyDisableAntiSpyware
474+ }
475+
476+ # Optional: EICAR synthetic detection test
477+ if ($Eicar.IsPresent ) {
478+ $eicarCheck = [ordered ]@ { name = ' EicarSyntheticDetection' ; expected = $null ; actual = $null ; result = ' SKIP' }
479+ if (-not $Force.IsPresent ) {
480+ $eicarCheck.actual = ' not-run (add -Force to perform test)'
481+ $eicarCheck.result = ' SKIP'
482+ } elseif ($expectResolved -ne ' Enabled' ) {
483+ $eicarCheck.actual = ' skipped (Defender expected disabled; EICAR test only valid when enabled)'
484+ $eicarCheck.result = ' SKIP'
485+ } else {
486+ # Write EICAR standard test string, wait briefly, check if it got quarantined.
487+ # This string is split across chars so the script itself doesn't trip AV on disk.
488+ $eicarStr = ' X5O!P%@AP' + ' [4\PZX54(P^)7CC)7}$EICAR-STANDARD-ANTIVIRUS-TEST-FILE!$H+H' + ' *'
489+ $tempDir = Join-Path $env: TEMP ' DefenderControl-Verify'
490+ try { if (-not (Test-Path $tempDir )) { New-Item - Path $tempDir - ItemType Directory - Force | Out-Null } } catch {}
491+ $eicarPath = Join-Path $tempDir (" eicar-" + [guid ]::NewGuid().ToString(' N' ) + ' .com' )
492+ try {
493+ [System.IO.File ]::WriteAllText($eicarPath , $eicarStr , [System.Text.Encoding ]::ASCII)
494+ Start-Sleep - Milliseconds 2500
495+ $eicarStillThere = Test-Path - LiteralPath $eicarPath
496+ if (-not $eicarStillThere ) {
497+ $eicarCheck.actual = ' quarantined (Defender detected + removed EICAR test file)'
498+ $eicarCheck.result = ' PASS'
499+ } else {
500+ $eicarCheck.actual = ' still-present (Defender did NOT detect EICAR within 2.5s)'
501+ $eicarCheck.result = ' FAIL'
502+ try { Remove-Item - LiteralPath $eicarPath - Force - ErrorAction SilentlyContinue } catch {}
503+ }
504+ } catch {
505+ $eicarCheck.actual = " write-failed: $ ( $_.Exception.Message ) "
506+ $eicarCheck.result = ' SKIP'
507+ } finally {
508+ # Best-effort cleanup (if Defender didn't quarantine, or we errored)
509+ try { if (Test-Path - LiteralPath $eicarPath ) { Remove-Item - LiteralPath $eicarPath - Force - ErrorAction SilentlyContinue } } catch {}
510+ try { if ((Get-ChildItem - Path $tempDir - ErrorAction SilentlyContinue).Count -eq 0 ) {
511+ Remove-Item - Path $tempDir - Force - Recurse - ErrorAction SilentlyContinue
512+ } } catch {}
513+ }
514+ }
515+ $checks.Add ($eicarCheck ) | Out-Null
516+ }
517+
518+ $failCount = @ ($checks | Where-Object { $_.result -eq ' FAIL' }).Count
519+ $overall = if ($failCount -eq 0 ) { ' PASS' } else { ' FAIL' }
520+
521+ $report = [ordered ]@ {
522+ timestamp = (Get-Date ).ToString(' o' )
523+ host = $env: COMPUTERNAME
524+ expectation = $expectResolved
525+ expectationSource = $Expect
526+ tamperProtected = $state.IsTamperProtected
527+ overall = $overall
528+ failCount = $failCount
529+ checks = @ ($checks )
530+ }
531+
532+ if ($Json.IsPresent ) {
533+ [Console ]::Out.WriteLine(($report | ConvertTo-Json - Depth 6 ))
534+ } else {
535+ Write-CliLine (" Defender Control Verify - expect: {0} (source: {1})" -f $report.expectation , $report.expectationSource )
536+ Write-CliLine (" Overall: {0} FailCount: {1} TamperProtected: {2}" -f $report.overall , $report.failCount , $report.tamperProtected )
537+ Write-CliLine ' ---'
538+ foreach ($c in $checks ) {
539+ $mark = switch ($c.result ) { ' PASS' { ' PASS' }; ' FAIL' { ' FAIL' }; ' SKIP' { ' SKIP' } }
540+ Write-CliLine (" [{0}] {1,-30} expected={2} actual={3}" -f $mark , $c.name , $c.expected , $c.actual )
541+ }
542+ }
543+
544+ if ($state.IsTamperProtected -eq $true ) { exit $script :EXIT_TAMPER_BLOCKED }
545+ if ($overall -eq ' PASS' ) { exit $script :EXIT_OK } else { exit $script :EXIT_VERIFY_FAIL }
546+ }
547+
410548function Invoke-CliMode {
411549 param (
412550 [string ]$Mode ,
551+ [string ]$Expect = ' Auto' ,
413552 [switch ]$Json ,
414- [switch ]$Silent
553+ [switch ]$Silent ,
554+ [switch ]$Eicar ,
555+ [switch ]$Force
415556 )
416557
417558 $script :Silent = $Silent.IsPresent
418559
419560 switch ($Mode ) {
420- { $_ -in ' Status' , ' Health' , ' Verify' } {
421- $extended = ($_ -in ' Health' , ' Verify' )
561+ ' Verify' {
562+ Invoke-VerifyMode - Expect $Expect - Json:$Json - Eicar:$Eicar - Force:$Force
563+ exit $script :EXIT_USAGE # defensive
564+ }
565+ { $_ -in ' Status' , ' Health' } {
566+ $extended = ($_ -eq ' Health' )
422567 $state = Get-DefenderState - Extended:$extended
423568
424569 if ($Json.IsPresent ) {
@@ -522,7 +667,7 @@ function Invoke-CliMode {
522667}
523668
524669if ($script :IsCliMode ) {
525- Invoke-CliMode - Mode $Mode - Json:$Json - Silent:$Silent
670+ Invoke-CliMode - Mode $Mode - Expect $Expect - Json:$Json - Silent:$Silent - Eicar: $Eicar - Force: $Force
526671 # Invoke-CliMode always exits; defensive fall-through:
527672 exit $script :EXIT_USAGE
528673}
0 commit comments