-
Notifications
You must be signed in to change notification settings - Fork 339
fix(install): detect Windows Defender AV block and emit specific guidance #1408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -283,6 +283,23 @@ function Test-AccessDeniedError { | |
| return ($Text -match 'Access is denied' -or $Text -match '0x80070005') | ||
| } | ||
|
|
||
| function Test-AntivirusBlockError { | ||
| # Defender / 3rd-party AV real-time protection blocks CreateProcess on | ||
| # binaries it flags with HRESULT 0x800700E1 (ERROR_VIRUS_INFECTED) or | ||
| # 0x800704EC (ERROR_VIRUS_DELETED). PowerShell surfaces these as | ||
| # "Operation did not complete successfully because the file contains a | ||
| # virus or potentially unwanted software". Our PyInstaller-built apm.exe | ||
| # is unsigned, which routinely trips false-positive heuristics. | ||
| param([string]$Text) | ||
| if (-not $Text) { return $false } | ||
| return ( | ||
| $Text -match 'contains a virus' -or | ||
| $Text -match 'potentially unwanted software' -or | ||
| $Text -match '0x800700E1' -or | ||
| $Text -match '0x800704EC' | ||
| ) | ||
| } | ||
|
|
||
| function Write-AppControlGuidance { | ||
| param( | ||
| [string]$Path, | ||
|
|
@@ -309,6 +326,35 @@ function Write-AppControlGuidance { | |
| Write-Host "" | ||
| } | ||
|
|
||
| function Write-AntivirusGuidance { | ||
| param( | ||
| [string]$Path, | ||
| [string]$TargetInstallDir | ||
| ) | ||
| Write-Host "" | ||
| Write-ErrorText "An antivirus product blocked execution of $Path." | ||
| Write-Host "Windows Defender (or another real-time scanner) flagged the binary as" | ||
| Write-Host "potentially unwanted software. The apm.exe release is built with" | ||
| Write-Host "PyInstaller and is currently unsigned, which routinely trips false-" | ||
| Write-Host "positive heuristics. The file is not actually malicious." | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agreed. Removed the unconditional 'not actually malicious' claim, replaced with conditional framing ('most blocks of apm.exe are false positives, but you should verify integrity before excluding it'), and added an explicit step before any exclusion advice: verify the SHA256 against the published |
||
| Write-Host "" | ||
| Write-Info "Options to unblock:" | ||
| if ($TargetInstallDir) { | ||
| Write-Host " 1. Add a Defender exclusion for the install directory (run in an" | ||
| Write-Host " elevated PowerShell, then rerun this installer):" | ||
| Write-Host " Add-MpPreference -ExclusionPath '$TargetInstallDir'" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9040379. Now escapes via |
||
| } else { | ||
| Write-Host " 1. Add a Defender exclusion for apm.exe (run in an elevated" | ||
| Write-Host " PowerShell, then rerun this installer):" | ||
| Write-Host " Add-MpPreference -ExclusionProcess 'apm.exe'" | ||
| } | ||
| Write-Host " 2. Install via pip into your user site (avoids the binary entirely):" | ||
| Write-Host " pip install --user apm-cli" | ||
| Write-Host " 3. Help us get the false positive cleared by submitting the binary" | ||
| Write-Host " to Microsoft: https://www.microsoft.com/en-us/wdsi/filesubmission" | ||
| Write-Host "" | ||
| } | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Banner | ||
| # --------------------------------------------------------------------------- | ||
|
|
@@ -645,8 +691,11 @@ try { | |
|
|
||
| if ($testFailure) { | ||
| $denied = Test-AccessDeniedError -Text $testFailure | ||
| $avBlocked = Test-AntivirusBlockError -Text $testFailure | ||
| Write-ErrorText "Downloaded binary failed to run: $testFailure" | ||
| if ($denied) { | ||
| if ($avBlocked) { | ||
| Write-AntivirusGuidance -Path $stagedExe -TargetInstallDir $releaseDir | ||
| } elseif ($denied) { | ||
| Write-AppControlGuidance -Path $stagedExe -TargetInstallDir $releaseDir | ||
| } | ||
| Remove-Item -Recurse -Force $stagingDir -ErrorAction SilentlyContinue | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -145,6 +145,70 @@ function Test-MoveThenTestOrdering { | |
| } | ||
| } | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Test 2b: Test-AntivirusBlockError detects Windows Defender / AV signatures. | ||
| # Issue: Defender flags the unsigned PyInstaller binary as PUA (HRESULT | ||
| # 0x800700E1), and the installer must distinguish that from AppLocker / | ||
| # WDAC denial so it can emit the right guidance (exclusion + pip fallback, | ||
| # not allow-list rule). | ||
| # --------------------------------------------------------------------------- | ||
|
|
||
| function Test-AntivirusDetector { | ||
| Write-Step "Test 2b: Test-AntivirusBlockError matches Defender PUA signatures" | ||
|
|
||
| $content = Get-Content $InstallScript -Raw | ||
| $pattern = '(?s)function Test-AntivirusBlockError\s*\{.*?\n\}' | ||
| $match = [regex]::Match($content, $pattern) | ||
| Assert-True $match.Success "Extracted Test-AntivirusBlockError from install.ps1" | ||
| if (-not $match.Success) { return } | ||
|
|
||
| $accessPattern = '(?s)function Test-AccessDeniedError\s*\{.*?\n\}' | ||
| $accessMatch = [regex]::Match($content, $accessPattern) | ||
| Assert-True $accessMatch.Success "Extracted Test-AccessDeniedError from install.ps1" | ||
| if (-not $accessMatch.Success) { return } | ||
|
|
||
| $childScript = @" | ||
| `$ErrorActionPreference = 'Stop' | ||
| $($match.Value) | ||
| $($accessMatch.Value) | ||
|
|
||
| # Real Defender failure text from issue #1389 follow-up: | ||
| `$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" | ||
| `$puaMsg = "blocked: potentially unwanted software detected" | ||
| `$hresultMsg = "CreateProcess failed with 0x800700E1" | ||
| `$accessMsg = "Program 'apm.exe' failed to run: Access is denied" | ||
| `$benignMsg = "exit code 1 - apm: command not found" | ||
|
|
||
| `$results = @{ | ||
| defender_match = (Test-AntivirusBlockError -Text `$defenderMsg) | ||
| pua_match = (Test-AntivirusBlockError -Text `$puaMsg) | ||
| hresult_match = (Test-AntivirusBlockError -Text `$hresultMsg) | ||
| access_no_av = (-not (Test-AntivirusBlockError -Text `$accessMsg)) | ||
| benign_no_av = (-not (Test-AntivirusBlockError -Text `$benignMsg)) | ||
| empty_no_av = (-not (Test-AntivirusBlockError -Text '')) | ||
| # Cross-check: the Defender message must NOT be classified as AppLocker. | ||
| defender_not_access = (-not (Test-AccessDeniedError -Text `$defenderMsg)) | ||
| } | ||
| `$results | ConvertTo-Json -Compress | ||
| "@ | ||
|
|
||
| $tempScript = [System.IO.Path]::GetTempFileName() + ".ps1" | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9040379. Replaced |
||
| try { | ||
| Set-Content -Path $tempScript -Value $childScript -Encoding UTF8 | ||
| $json = & pwsh -NoProfile -NonInteractive -File $tempScript 2>&1 | Select-Object -Last 1 | ||
|
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in 9040379. Switched to explicit |
||
| $r = $json | ConvertFrom-Json | ||
| Assert-True ([bool]$r.defender_match) "Matches real Defender 'contains a virus' message" | ||
| Assert-True ([bool]$r.pua_match) "Matches 'potentially unwanted software' message" | ||
| Assert-True ([bool]$r.hresult_match) "Matches HRESULT 0x800700E1" | ||
| Assert-True ([bool]$r.access_no_av) "Does not misclassify 'Access is denied' as AV block" | ||
| Assert-True ([bool]$r.benign_no_av) "Does not misclassify benign failure text as AV block" | ||
| Assert-True ([bool]$r.empty_no_av) "Does not match empty input" | ||
| Assert-True ([bool]$r.defender_not_access) "Defender message is not classified as AppLocker/WDAC" | ||
| } finally { | ||
| Remove-Item -Path $tempScript -Force -ErrorAction SilentlyContinue | ||
| } | ||
| } | ||
|
|
||
| # --------------------------------------------------------------------------- | ||
| # Test 3: Run install.ps1 end-to-end into an isolated prefix. | ||
| # --------------------------------------------------------------------------- | ||
|
|
@@ -392,6 +456,7 @@ Write-Host "" | |
|
|
||
| Test-Sha256Fallback | ||
| Test-MoveThenTestOrdering | ||
| Test-AntivirusDetector | ||
| Test-EndToEndInstall | ||
| Test-CrossVersionUpgrade | ||
| Test-SameVersionReinstall | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Confirmed and fixed in 9040379.
0x800704ECisERROR_ACCESS_DISABLED_BY_POLICY(Win32 1260), a Group Policy / SRP block — not AV.ERROR_VIRUS_DELETEDis Win32 226 =0x800700E2. Dropped0x800704ECfromTest-AntivirusBlockError, added the correct0x800700E2, and routed0x800704ECplus the standard 'blocked by group policy' message intoTest-AccessDeniedErrorwhere it belongs. Added a cross-class disambiguation test so we cannot regress this.