Skip to content

Commit 8ec6f34

Browse files
committed
feat: verification suite with -Expect assertions and opt-in EICAR test
-Mode Verify is now a pass/fail assertion pass rather than a Health alias. Independent from Status/Health: it scores the current Defender state against an expected shape and exits with code 0 (PASS) or 5 (new EXIT_VERIFY_FAIL). Expectation sources: -Expect Enabled asserts RTP+AV+Antispyware on, WinDefend Running, StartType != Disabled, PolicyDisableAntiSpyware != 1 -Expect Disabled asserts at least one of: RTP off / AV off / WinDefend Stopped / StartType Disabled / PolicyDisableAntiSpyware=1 -Expect Auto (default) infers expectation from DefenderEffectivelyEnabled JSON shape: { timestamp, host, expectation, expectationSource, tamperProtected, overall, failCount, checks: [{name, expected, actual, result}] } Optional EICAR synthetic detection test (-Eicar -Force): Writes the EICAR standard AV-signature test string (split in the source so the script itself doesn't trip AV on disk) to a GUID-keyed temp file, waits 2.5s, reports whether Defender quarantined it. Cleans up regardless of outcome. Gated behind -Force so it never runs accidentally. Tamper-protected systems short-circuit to exit 2 regardless of the overall verdict (the reading itself may be misleading when changes are blocked). Unit-tested via .factory/test-verify.ps1 across 5 scenarios: Enabled+Enabled=PASS, Enabled+Disabled=FAIL, Disabled+Disabled=PASS, TamperProtected=exit 2, JSON shape validation.
1 parent b2bc98a commit 8ec6f34

2 files changed

Lines changed: 163 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,18 @@ All notable changes to DefenderControl will be documented in this file.
2525
schema version, dry-run flag, firewall before/after, third-party AV list,
2626
phases completed. `-Mode Manifest` prints the latest manifest; `-Json` emits
2727
raw.
28+
- **Verification suite**: `-Mode Verify` is now a pass/fail assertion pass
29+
rather than a Health alias. `-Expect Enabled` asserts RTP/AV/service/GP match
30+
the "fully enabled" shape; `-Expect Disabled` asserts at least one signal
31+
confirms disable; `-Expect Auto` (default) infers from current effective
32+
state. JSON output shape:
33+
`{expectation, overall, failCount, checks: [{name, expected, actual, result}]}`.
34+
- **EICAR synthetic detection test**: opt-in via `-Mode Verify -Eicar -Force`.
35+
Writes the standard EICAR AV-signature test string to a GUID-keyed file under
36+
`$env:TEMP\DefenderControl-Verify`, waits 2.5s, and reports whether Defender
37+
quarantined it. The check is gated behind `-Force` and the path is always
38+
cleaned up on exit.
39+
- New exit code `5` for verification failure (distinct from `1` partial).
2840

2941
### Changed
3042
- Self-elevation now forwards all original arguments through the UAC re-launch

DefenderControl.ps1

Lines changed: 151 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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
128141
Flags:
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
134150
Exit 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
137154
Note: -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+
410548
function 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

524669
if ($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

Comments
 (0)