Skip to content

Commit 9d1ae4e

Browse files
danielmeppielDaniel MeppielCopilot
authored
fix(install): detect Windows Defender AV block and emit specific guidance (#1408)
* fix(install): detect Windows Defender AV block and emit specific guidance The Windows installer (and 'apm self-update') failed on hosts where Defender or another real-time scanner flags the unsigned PyInstaller apm.exe as potentially unwanted software (HRESULT 0x800700E1). The binary smoke test threw 'Operation did not complete successfully because the file contains a virus or potentially unwanted software', which Test-AccessDeniedError did not match, so the installer fell through to a generic 'failed to run' message and a pip fallback that itself died on hosts running an unsupported Python (e.g. 3.14). Changes: - install.ps1: add Test-AntivirusBlockError mirroring the existing Test-AccessDeniedError contract. Matches the Defender PUA message, the generic 'contains a virus' / 'potentially unwanted software' phrasing, and HRESULTs 0x800700E1 and 0x800704EC. - install.ps1: add Write-AntivirusGuidance with three actionable options - Add-MpPreference -ExclusionPath on the install root, pip --user fallback, and the Microsoft false-positive submission URL. Wired into the testFailure branch ahead of the AppLocker guidance check; the two error classes are distinct. - docs/installation.md: add a troubleshooting subsection mirroring the existing AppLocker/WDAC one, documenting the same three recovery options. - scripts/windows/test-install-script.ps1: add Test-AntivirusDetector covering real Defender output, generic PUA text, both HRESULTs, the cross-class case (access-denied must not be classified as AV), and empty/benign strings. Cases verified locally against the install.ps1 functions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fixup(windows-installer): correct AV vs policy HRESULT classification Address review feedback on PR #1408: 1. CRITICAL: 0x800704EC is ERROR_ACCESS_DISABLED_BY_POLICY (GPO/SRP), NOT ERROR_VIRUS_DELETED. The correct deleted-by-AV HRESULT is 0x800700E2. Misclassifying a policy block as AV would prompt users to add a Defender exclusion to fix what is actually a Group Policy rule -- net regression vs pre-PR generic handler. - Test-AntivirusBlockError: drop 0x800704EC, add 0x800700E2. - Test-AccessDeniedError: add 0x800704EC and the standard SRP/GPO 'blocked by group policy' message string. These belong in the AppControl/AppLocker guidance bucket. 2. Soften 'not actually malicious' framing. In a real supply-chain compromise this code path would still fire; an unconditional safety claim is poor security hygiene. Reword conditionally and tell users to verify the published .sha256 sidecar BEFORE excluding the binary. 3. Single-quote-escape $TargetInstallDir in the printed Add-MpPreference command so the suggestion stays valid for paths containing a single quote. 4. Stop leaking GetTempFileName() zero-byte companions in the test harness. Use GetRandomFileName under $env:TEMP for both .ps1 temp files in scripts/windows/test-install-script.ps1. 5. Replace brittle 'Select-Object -Last 1' JSON parsing with explicit ---APM-JSON-BEGIN---/---APM-JSON-END--- sentinels so child-pwsh profile output cannot corrupt the parsed JSON. 6. Expand Test-AntivirusDetector cases to cover both corrected AV HRESULTs (0x800700E1, 0x800700E2) and the GPO/SRP cross-class disambiguation (0x800704EC must route to AccessDenied, not AV). Validated locally with pwsh: 9/9 cross-class detector cases pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent d1b64f8 commit 9d1ae4e

2 files changed

Lines changed: 160 additions & 6 deletions

File tree

install.ps1

Lines changed: 73 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,39 @@ function Get-Sha256Hex {
275275
}
276276

277277
function Test-AccessDeniedError {
278-
# AppLocker / WDAC / App Control for Business denies CreateProcess on EXEs
279-
# under user-writable paths (e.g. %TEMP%, %LOCALAPPDATA%\Temp) with HRESULT
280-
# 0x80070005 (E_ACCESSDENIED), surfaced by PowerShell as "Access is denied".
278+
# AppLocker / WDAC / App Control for Business / SRP / Group Policy deny
279+
# CreateProcess on EXEs under user-writable paths (e.g. %TEMP%,
280+
# %LOCALAPPDATA%\Temp) with one of:
281+
# 0x80070005 (E_ACCESSDENIED, Win32 5) -> "Access is denied"
282+
# 0x800704EC (ERROR_ACCESS_DISABLED_BY_POLICY, -> "This program is
283+
# Win32 1260) blocked by group policy"
284+
# Both belong in the AppControl/AppLocker guidance bucket.
281285
param([string]$Text)
282286
if (-not $Text) { return $false }
283-
return ($Text -match 'Access is denied' -or $Text -match '0x80070005')
287+
return (
288+
$Text -match 'Access is denied' -or
289+
$Text -match 'blocked by group policy' -or
290+
$Text -match '0x80070005' -or
291+
$Text -match '0x800704EC'
292+
)
293+
}
294+
295+
function Test-AntivirusBlockError {
296+
# Defender / 3rd-party AV real-time protection blocks CreateProcess on
297+
# binaries it flags with HRESULT 0x800700E1 (ERROR_VIRUS_INFECTED,
298+
# Win32 225) or 0x800700E2 (ERROR_VIRUS_DELETED, Win32 226). PowerShell
299+
# surfaces these as "Operation did not complete successfully because
300+
# the file contains a virus or potentially unwanted software". Our
301+
# PyInstaller-built apm.exe is unsigned, which routinely trips
302+
# false-positive heuristics.
303+
param([string]$Text)
304+
if (-not $Text) { return $false }
305+
return (
306+
$Text -match 'contains a virus' -or
307+
$Text -match 'potentially unwanted software' -or
308+
$Text -match '0x800700E1' -or
309+
$Text -match '0x800700E2'
310+
)
284311
}
285312

286313
function Write-AppControlGuidance {
@@ -309,6 +336,44 @@ function Write-AppControlGuidance {
309336
Write-Host ""
310337
}
311338

339+
function Write-AntivirusGuidance {
340+
param(
341+
[string]$Path,
342+
[string]$TargetInstallDir
343+
)
344+
Write-Host ""
345+
Write-ErrorText "An antivirus product blocked execution of $Path."
346+
Write-Host "Windows Defender (or another real-time scanner) flagged the binary."
347+
Write-Host "The apm.exe release is built with PyInstaller and is currently"
348+
Write-Host "unsigned, which routinely trips false-positive heuristics on"
349+
Write-Host "unsigned binaries. Most blocks of apm.exe are false positives, but"
350+
Write-Host "you should verify integrity before excluding it:"
351+
Write-Host ""
352+
Write-Host " 1. Verify the SHA256 of apm.exe against the published .sha256"
353+
Write-Host " sidecar on the release page before adding any AV exclusion."
354+
Write-Host " Do not exclude a binary whose checksum you have not verified."
355+
Write-Host ""
356+
Write-Info "If the checksum matches, options to unblock:"
357+
if ($TargetInstallDir) {
358+
# Single-quote-escape the path so the printed command stays valid
359+
# even if the install directory contains a "'" character (rare but
360+
# possible in usernames).
361+
$escapedDir = $TargetInstallDir -replace "'", "''"
362+
Write-Host " a. Add a Defender exclusion for the install directory (run in"
363+
Write-Host " an elevated PowerShell, then rerun this installer):"
364+
Write-Host " Add-MpPreference -ExclusionPath '$escapedDir'"
365+
} else {
366+
Write-Host " a. Add a Defender exclusion for apm.exe (run in an elevated"
367+
Write-Host " PowerShell, then rerun this installer):"
368+
Write-Host " Add-MpPreference -ExclusionProcess 'apm.exe'"
369+
}
370+
Write-Host " b. Install via pip into your user site (avoids the binary entirely):"
371+
Write-Host " pip install --user apm-cli"
372+
Write-Host " c. Help us get the false positive cleared by submitting the binary"
373+
Write-Host " to Microsoft: https://www.microsoft.com/en-us/wdsi/filesubmission"
374+
Write-Host ""
375+
}
376+
312377
# ---------------------------------------------------------------------------
313378
# Banner
314379
# ---------------------------------------------------------------------------
@@ -645,8 +710,11 @@ try {
645710

646711
if ($testFailure) {
647712
$denied = Test-AccessDeniedError -Text $testFailure
713+
$avBlocked = Test-AntivirusBlockError -Text $testFailure
648714
Write-ErrorText "Downloaded binary failed to run: $testFailure"
649-
if ($denied) {
715+
if ($avBlocked) {
716+
Write-AntivirusGuidance -Path $stagedExe -TargetInstallDir $releaseDir
717+
} elseif ($denied) {
650718
Write-AppControlGuidance -Path $stagedExe -TargetInstallDir $releaseDir
651719
}
652720
Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue

scripts/windows/test-install-script.ps1

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ Remove-Module Microsoft.PowerShell.Utility -Force -ErrorAction SilentlyContinue
8383
$($match.Value)
8484
Write-Output (Get-Sha256Hex -Path '$tempFile')
8585
"@
86-
$childScriptPath = [System.IO.Path]::GetTempFileName() + ".ps1"
86+
$childScriptPath = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName() + ".ps1")
8787
Set-Content -Path $childScriptPath -Value $childScript -Encoding UTF8
8888
try {
8989
$actual = & powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File $childScriptPath 2>&1
@@ -145,6 +145,91 @@ function Test-MoveThenTestOrdering {
145145
}
146146
}
147147

148+
# ---------------------------------------------------------------------------
149+
# Test 2b: Test-AntivirusBlockError detects Windows Defender / AV signatures.
150+
# Issue: Defender flags the unsigned PyInstaller binary as PUA (HRESULT
151+
# 0x800700E1), and the installer must distinguish that from AppLocker /
152+
# WDAC denial so it can emit the right guidance (exclusion + pip fallback,
153+
# not allow-list rule).
154+
# ---------------------------------------------------------------------------
155+
156+
function Test-AntivirusDetector {
157+
Write-Step "Test 2b: Test-AntivirusBlockError matches Defender PUA signatures"
158+
159+
$content = Get-Content $InstallScript -Raw
160+
$pattern = '(?s)function Test-AntivirusBlockError\s*\{.*?\n\}'
161+
$match = [regex]::Match($content, $pattern)
162+
Assert-True $match.Success "Extracted Test-AntivirusBlockError from install.ps1"
163+
if (-not $match.Success) { return }
164+
165+
$accessPattern = '(?s)function Test-AccessDeniedError\s*\{.*?\n\}'
166+
$accessMatch = [regex]::Match($content, $accessPattern)
167+
Assert-True $accessMatch.Success "Extracted Test-AccessDeniedError from install.ps1"
168+
if (-not $accessMatch.Success) { return }
169+
170+
$childScript = @"
171+
`$ErrorActionPreference = 'Stop'
172+
$($match.Value)
173+
$($accessMatch.Value)
174+
175+
# Real Defender failure text from issue #1389 follow-up:
176+
`$defenderMsg = "Program 'apm.exe' failed to run: Operation did not complete successfully because the file contains a virus or potentially unwanted softwareAt C:\\Users\\X\\AppData\\Local\\Temp\\tmpfoo.ps1:639 char:23"
177+
`$puaMsg = "blocked: potentially unwanted software detected"
178+
`$hresultMsg = "CreateProcess failed with 0x800700E1"
179+
`$deletedMsg = "Operation failed with 0x800700E2 (file removed by antivirus)"
180+
`$accessMsg = "Program 'apm.exe' failed to run: Access is denied"
181+
`$gpoMsg = "This program is blocked by group policy. For more information, contact your system administrator. (0x800704EC)"
182+
`$benignMsg = "exit code 1 - apm: command not found"
183+
184+
`$results = @{
185+
defender_match = (Test-AntivirusBlockError -Text `$defenderMsg)
186+
pua_match = (Test-AntivirusBlockError -Text `$puaMsg)
187+
hresult_match = (Test-AntivirusBlockError -Text `$hresultMsg)
188+
deleted_match = (Test-AntivirusBlockError -Text `$deletedMsg)
189+
access_no_av = (-not (Test-AntivirusBlockError -Text `$accessMsg))
190+
gpo_no_av = (-not (Test-AntivirusBlockError -Text `$gpoMsg))
191+
benign_no_av = (-not (Test-AntivirusBlockError -Text `$benignMsg))
192+
empty_no_av = (-not (Test-AntivirusBlockError -Text ''))
193+
# Cross-class: AppLocker/SRP/GPO must NOT be misclassified as AV.
194+
defender_not_access = (-not (Test-AccessDeniedError -Text `$defenderMsg))
195+
# GPO/SRP block (0x800704EC) belongs in the AppControl bucket, not AV.
196+
gpo_is_access = (Test-AccessDeniedError -Text `$gpoMsg)
197+
access_is_access = (Test-AccessDeniedError -Text `$accessMsg)
198+
}
199+
Write-Output '---APM-JSON-BEGIN---'
200+
Write-Output (`$results | ConvertTo-Json -Compress)
201+
Write-Output '---APM-JSON-END---'
202+
"@
203+
204+
# Use GetRandomFileName so we don't leak the GetTempFileName-created
205+
# zero-byte .tmp companion file every run.
206+
$tempScript = [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName() + ".ps1")
207+
try {
208+
Set-Content -Path $tempScript -Value $childScript -Encoding UTF8
209+
$raw = & pwsh -NoProfile -NonInteractive -File $tempScript 2>&1
210+
$lines = ($raw | Out-String) -split "`r?`n"
211+
$begin = [Array]::IndexOf($lines, '---APM-JSON-BEGIN---')
212+
$end = [Array]::IndexOf($lines, '---APM-JSON-END---')
213+
Assert-True (($begin -ge 0) -and ($end -gt $begin)) "Located JSON sentinels in child output"
214+
if (($begin -lt 0) -or ($end -le $begin)) { return }
215+
$json = ($lines[($begin + 1)..($end - 1)] -join '').Trim()
216+
$r = $json | ConvertFrom-Json
217+
Assert-True ([bool]$r.defender_match) "Matches real Defender 'contains a virus' message"
218+
Assert-True ([bool]$r.pua_match) "Matches 'potentially unwanted software' message"
219+
Assert-True ([bool]$r.hresult_match) "Matches HRESULT 0x800700E1 (ERROR_VIRUS_INFECTED)"
220+
Assert-True ([bool]$r.deleted_match) "Matches HRESULT 0x800700E2 (ERROR_VIRUS_DELETED)"
221+
Assert-True ([bool]$r.access_no_av) "Does not misclassify 'Access is denied' as AV block"
222+
Assert-True ([bool]$r.gpo_no_av) "Does not misclassify GPO/SRP block (0x800704EC) as AV"
223+
Assert-True ([bool]$r.benign_no_av) "Does not misclassify benign failure text as AV block"
224+
Assert-True ([bool]$r.empty_no_av) "Does not match empty input"
225+
Assert-True ([bool]$r.defender_not_access) "Defender message is not classified as AppLocker/WDAC"
226+
Assert-True ([bool]$r.gpo_is_access) "GPO/SRP block (0x800704EC) routes to AppControl guidance"
227+
Assert-True ([bool]$r.access_is_access) "'Access is denied' still routes to AppControl guidance"
228+
} finally {
229+
Remove-Item -Path $tempScript -Force -ErrorAction SilentlyContinue
230+
}
231+
}
232+
148233
# ---------------------------------------------------------------------------
149234
# Test 3: Run install.ps1 end-to-end into an isolated prefix.
150235
# ---------------------------------------------------------------------------
@@ -392,6 +477,7 @@ Write-Host ""
392477

393478
Test-Sha256Fallback
394479
Test-MoveThenTestOrdering
480+
Test-AntivirusDetector
395481
Test-EndToEndInstall
396482
Test-CrossVersionUpgrade
397483
Test-SameVersionReinstall

0 commit comments

Comments
 (0)