Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
}
Comment on lines +295 to +311

Copy link
Copy Markdown
Collaborator Author

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. 0x800704EC is ERROR_ACCESS_DISABLED_BY_POLICY (Win32 1260), a Group Policy / SRP block — not AV. ERROR_VIRUS_DELETED is Win32 226 = 0x800700E2. Dropped 0x800704EC from Test-AntivirusBlockError, added the correct 0x800700E2, and routed 0x800704EC plus the standard 'blocked by group policy' message into Test-AccessDeniedError where it belongs. Added a cross-class disambiguation test so we cannot regress this.


function Write-AppControlGuidance {
param(
[string]$Path,
Expand All @@ -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."

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The 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 .sha256 sidecar. Fixed in 9040379.

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'"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9040379. Now escapes via $escapedDir = $TargetInstallDir -replace "'", "''" before interpolation so the printed command stays valid for paths containing a single quote.

} 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
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions scripts/windows/test-install-script.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9040379. Replaced GetTempFileName() + '.ps1' with [System.IO.Path]::Combine($env:TEMP, [System.IO.Path]::GetRandomFileName() + '.ps1') in both spots (Test-AntivirusDetector and the original SHA256 fallback test on line 86, which had the same bug).

try {
Set-Content -Path $tempScript -Value $childScript -Encoding UTF8
$json = & pwsh -NoProfile -NonInteractive -File $tempScript 2>&1 | Select-Object -Last 1

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 9040379. Switched to explicit ---APM-JSON-BEGIN--- / ---APM-JSON-END--- sentinels emitted by the child script; the parent now slices lines between them and joins, so any profile warnings, formatter output, or extra whitespace from pwsh cannot corrupt the parsed JSON.

$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.
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -392,6 +456,7 @@ Write-Host ""

Test-Sha256Fallback
Test-MoveThenTestOrdering
Test-AntivirusDetector
Test-EndToEndInstall
Test-CrossVersionUpgrade
Test-SameVersionReinstall
Expand Down
Loading