diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f16e3b1d..a04a79209 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- Windows Defender false-positive (`Trojan:Win32/Bearfoos.B!ml`) mitigation: embed PE version info in Windows binary and disable UPX compression on Windows builds (#487) - `apm deps update` was a no-op -- rewrote to delegate to the install engine so lockfile, deployed files, and integration state are all refreshed correctly -- by @webmaxru (#493) ## [0.8.6] - 2026-03-27 diff --git a/build/apm.spec b/build/apm.spec index 6ed282068..d6401dd79 100644 --- a/build/apm.spec +++ b/build/apm.spec @@ -13,10 +13,74 @@ def is_upx_available(): except (subprocess.CalledProcessError, FileNotFoundError): return False +def should_use_upx(): + """Enable UPX only on non-Windows platforms where it is available. + + UPX-compressed PE binaries trigger ML-based AV false positives + (e.g. Trojan:Win32/Bearfoos.B!ml) on Windows Defender. + """ + if sys.platform == 'win32': + return False + return is_upx_available() + # Get the directory where this spec file is located spec_dir = Path(SPECPATH) repo_root = spec_dir.parent +# --- Windows PE version info (reduces AV false positives) --- +# Anonymous executables without metadata score poorly in AV ML models. +# Embedding company, product, and version info into the PE header provides +# positive trust signals to heuristic scanners like Windows Defender. +def _read_version_from_pyproject(repo_root): + """Read version string from pyproject.toml and return as 4-tuple.""" + import re + pyproject = repo_root / 'pyproject.toml' + if not pyproject.exists(): + return (0, 0, 0, 0) + content = pyproject.read_text(encoding='utf-8') + match = re.search(r'version\s*=\s*["\']([^"\']+)["\']', content) + if not match: + return (0, 0, 0, 0) + parts = re.match(r'(\d+)\.(\d+)\.(\d+)', match.group(1)) + if not parts: + return (0, 0, 0, 0) + return (int(parts.group(1)), int(parts.group(2)), int(parts.group(3)), 0) + +_win_version_info = None +if sys.platform == 'win32': + try: + from PyInstaller.utils.win32 import versioninfo as vi + _ver = _read_version_from_pyproject(repo_root) + _ver_str = f'{_ver[0]}.{_ver[1]}.{_ver[2]}' + _win_version_info = vi.VSVersionInfo( + ffi=vi.FixedFileInfo( + filevers=_ver, + prodvers=_ver, + mask=0x3f, + flags=0x0, + OS=0x40004, # VOS_NT_WINDOWS32 + fileType=0x1, # VFT_APP + subtype=0x0, + ), + kids=[ + vi.StringFileInfo([vi.StringTable('040904B0', [ # Lang: US English (0409), Charset: Unicode (04B0) + vi.StringStruct('CompanyName', 'Microsoft'), + vi.StringStruct('FileDescription', + 'APM - Agent Package Manager'), + vi.StringStruct('FileVersion', _ver_str), + vi.StringStruct('InternalName', 'apm'), + vi.StringStruct('LegalCopyright', + 'Copyright (c) Microsoft Corporation'), + vi.StringStruct('OriginalFilename', 'apm.exe'), + vi.StringStruct('ProductName', 'APM'), + vi.StringStruct('ProductVersion', _ver_str), + ])]), + vi.VarFileInfo([vi.VarStruct('Translation', [1033, 1200])]), # LCID 1033 = en-US, Codepage 1200 = UTF-16 + ], + ) + except ImportError: + _win_version_info = None + # APM CLI entry point entry_point = repo_root / 'src' / 'apm_cli' / 'cli.py' @@ -240,7 +304,7 @@ exe = EXE( debug=False, bootloader_ignore_signals=False, strip=_strip, # Strip debug symbols (Unix only; corrupts Windows DLLs) - upx=is_upx_available(), # Enable UPX compression only if available + upx=should_use_upx(), # Enable UPX compression only if available (disabled on Windows) upx_exclude=[], runtime_tmpdir=None, console=True, @@ -249,6 +313,7 @@ exe = EXE( target_arch=None, codesign_identity=None, entitlements_file=None, + version=_win_version_info, ) coll = COLLECT( @@ -257,7 +322,7 @@ coll = COLLECT( a.zipfiles, a.datas, strip=_strip, - upx=is_upx_available(), + upx=should_use_upx(), upx_exclude=[], name='apm' ) diff --git a/tests/unit/test_build_spec.py b/tests/unit/test_build_spec.py new file mode 100644 index 000000000..b39a711c0 --- /dev/null +++ b/tests/unit/test_build_spec.py @@ -0,0 +1,245 @@ +"""Tests for build spec helper functions (build/apm.spec). + +``build/apm.spec`` is a PyInstaller spec file that is executed inside the +PyInstaller runtime. It cannot be imported normally because the PyInstaller +globals (``SPECPATH``, ``Analysis``, ``PYZ``, ``EXE``, ``COLLECT``) do not +exist outside a build context. + +Strategy +-------- +1. **Syntax check** -- ``compile()`` the raw spec source. Catches any Python + syntax errors introduced by edits. +2. **Function-level extraction** -- use ``ast`` to locate the helper function + definitions (``is_upx_available``, ``should_use_upx``, + ``_read_version_from_pyproject``) and ``exec`` only those definitions into a + controlled namespace. The rest of the spec (which uses PyInstaller globals) + is never executed. This is the same technique used in + ``tests/unit/test_ssl_cert_hook.py`` for the PyInstaller runtime hook. + +This approach keeps the tests hermetic, fast, and dependency-free. +""" + +import ast +import sys +from pathlib import Path + +import pytest + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _find_repo_root() -> Path: + """Walk up from this file until pyproject.toml is found (the repo root).""" + current = Path(__file__).resolve().parent + for candidate in [current] + list(current.parents): + if (candidate / "pyproject.toml").is_file(): + return candidate + raise RuntimeError("Cannot locate repository root (no pyproject.toml found)") + + +_REPO_ROOT = _find_repo_root() +_SPEC_FILE = _REPO_ROOT / "build" / "apm.spec" + + +def _extract_spec_helpers() -> str: + """Return a self-contained Python source snippet with only the helper + function definitions extracted from the spec file. + + The returned snippet can be safely ``exec``'d without any PyInstaller + globals present. Functions are emitted in source order so that + ``should_use_upx`` (which calls ``is_upx_available``) is always defined + after its dependency. + """ + spec_source = _SPEC_FILE.read_text(encoding="utf-8") + tree = ast.parse(spec_source) + lines = spec_source.splitlines() + + wanted = {"is_upx_available", "should_use_upx", "_read_version_from_pyproject"} + + # Standard-library imports the helper functions rely on. + preamble = [ + "import sys", + "import subprocess", + "import os", + "from pathlib import Path", + ] + + func_parts: list[str] = [] + for node in tree.body: # iterate in source order + if isinstance(node, ast.FunctionDef) and node.name in wanted: + func_src = "\n".join(lines[node.lineno - 1 : node.end_lineno]) + func_parts.append(func_src) + + if not func_parts: + raise RuntimeError( + f"No helper functions found in {_SPEC_FILE}. " + "Check that 'should_use_upx' and '_read_version_from_pyproject' still exist." + ) + + return "\n\n".join(preamble + func_parts) + + +def _make_helpers_ns(repo_root: Path | None = None) -> dict: + """Compile and execute the helper functions into a fresh namespace. + + ``repo_root`` is stored in the namespace for convenience -- callers pass + it explicitly to ``_read_version_from_pyproject(repo_root)`` at call time. + """ + ns: dict = {"repo_root": repo_root if repo_root is not None else _REPO_ROOT} + code = compile(_extract_spec_helpers(), "", "exec") + exec(code, ns) # noqa: S102 -- deliberate, controlled exec + return ns + + +# --------------------------------------------------------------------------- +# 1. Syntax check +# --------------------------------------------------------------------------- + +class TestSpecFileSyntax: + """The spec file must be valid Python at all times.""" + + def test_spec_file_exists(self): + assert _SPEC_FILE.is_file(), f"Expected spec file at {_SPEC_FILE}" + + def test_spec_file_compiles_without_syntax_errors(self): + """``compile()`` the raw source -- catches SyntaxError without executing + any PyInstaller-specific globals.""" + source = _SPEC_FILE.read_text(encoding="utf-8") + try: + compile(source, str(_SPEC_FILE), "exec") + except SyntaxError as exc: + pytest.fail(f"build/apm.spec contains a syntax error: {exc}") + + def test_spec_file_helper_functions_are_extractable(self): + """AST extraction must succeed and return at least two function defs.""" + snippet = _extract_spec_helpers() + assert "def should_use_upx" in snippet + assert "def _read_version_from_pyproject" in snippet + assert "def is_upx_available" in snippet + + +# --------------------------------------------------------------------------- +# 2. should_use_upx() +# --------------------------------------------------------------------------- + +class TestShouldUseUpx: + """``should_use_upx()`` disables UPX on Windows, delegates on other platforms.""" + + def test_returns_false_on_windows(self, monkeypatch): + """Regression guard: UPX must be disabled on win32 to avoid AV false positives.""" + monkeypatch.setattr(sys, "platform", "win32") + ns = _make_helpers_ns() + result = ns["should_use_upx"]() + assert result is False, "should_use_upx() must return False on win32" + + def test_delegates_to_is_upx_available_when_upx_present_on_linux(self, monkeypatch): + """On Linux, should_use_upx() returns True when UPX is installed.""" + monkeypatch.setattr(sys, "platform", "linux") + ns = _make_helpers_ns() + ns["is_upx_available"] = lambda: True # inject mock into helpers' namespace + result = ns["should_use_upx"]() + assert result is True + + def test_delegates_to_is_upx_available_when_upx_absent_on_linux(self, monkeypatch): + """On Linux, should_use_upx() returns False when UPX is not installed.""" + monkeypatch.setattr(sys, "platform", "linux") + ns = _make_helpers_ns() + ns["is_upx_available"] = lambda: False + result = ns["should_use_upx"]() + assert result is False + + def test_delegates_to_is_upx_available_on_darwin(self, monkeypatch): + """On macOS (darwin), should_use_upx() delegates correctly.""" + monkeypatch.setattr(sys, "platform", "darwin") + ns = _make_helpers_ns() + ns["is_upx_available"] = lambda: True + assert ns["should_use_upx"]() is True + + ns["is_upx_available"] = lambda: False + assert ns["should_use_upx"]() is False + + def test_never_calls_is_upx_available_on_windows(self, monkeypatch): + """On win32, is_upx_available() must not be invoked at all.""" + monkeypatch.setattr(sys, "platform", "win32") + ns = _make_helpers_ns() + + called = [] + ns["is_upx_available"] = lambda: called.append(True) or True + + result = ns["should_use_upx"]() + assert result is False + assert called == [], "is_upx_available() must not be called on Windows" + + +# --------------------------------------------------------------------------- +# 3. _read_version_from_pyproject() +# --------------------------------------------------------------------------- + +class TestReadVersionFromPyproject: + """``_read_version_from_pyproject()`` must parse semver strings robustly.""" + + def test_parses_actual_pyproject_version(self): + """Smoke-test: parses the real pyproject.toml in the repo.""" + ns = _make_helpers_ns(repo_root=_REPO_ROOT) + result = ns["_read_version_from_pyproject"](_REPO_ROOT) + major, minor, patch_v, build = result + # We expect a proper semver tuple, not the zero fallback + assert isinstance(major, int) and major >= 0 + assert isinstance(minor, int) and minor >= 0 + assert isinstance(patch_v, int) and patch_v >= 0 + assert build == 0, "Fourth element of the tuple must always be 0" + assert (major, minor, patch_v) != (0, 0, 0), ( + "Version parsed from pyproject.toml should not be all-zeros" + ) + + def test_parses_semver_correctly(self, tmp_path): + """Canonical semver ``major.minor.patch`` is mapped to a 4-tuple.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "1.23.4"\n', encoding="utf-8") + ns = _make_helpers_ns(repo_root=tmp_path) + assert ns["_read_version_from_pyproject"](tmp_path) == (1, 23, 4, 0) + + def test_parses_version_with_prerelease_suffix(self, tmp_path): + """Pre-release suffix (``1.2.3rc1``) is ignored; only digits are kept.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "2.0.0rc1"\n', encoding="utf-8") + ns = _make_helpers_ns(repo_root=tmp_path) + assert ns["_read_version_from_pyproject"](tmp_path) == (2, 0, 0, 0) + + def test_returns_zero_tuple_when_pyproject_missing(self, tmp_path): + """If pyproject.toml does not exist the function must return (0,0,0,0).""" + ns = _make_helpers_ns(repo_root=tmp_path) # tmp_path has no pyproject.toml + assert ns["_read_version_from_pyproject"](tmp_path) == (0, 0, 0, 0) + + def test_returns_zero_tuple_when_version_key_absent(self, tmp_path): + """pyproject.toml without a ``version =`` line returns (0,0,0,0).""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "my-app"\n', encoding="utf-8") + ns = _make_helpers_ns(repo_root=tmp_path) + assert ns["_read_version_from_pyproject"](tmp_path) == (0, 0, 0, 0) + + def test_returns_zero_tuple_for_non_numeric_version(self, tmp_path): + """A version string with no leading digit group returns (0,0,0,0).""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "alpha"\n', encoding="utf-8") + ns = _make_helpers_ns(repo_root=tmp_path) + assert ns["_read_version_from_pyproject"](tmp_path) == (0, 0, 0, 0) + + def test_returns_zero_tuple_for_empty_file(self, tmp_path): + """Empty pyproject.toml (no version key) returns (0,0,0,0).""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("", encoding="utf-8") + ns = _make_helpers_ns(repo_root=tmp_path) + assert ns["_read_version_from_pyproject"](tmp_path) == (0, 0, 0, 0) + + def test_result_is_four_tuple_of_ints(self, tmp_path): + """Return type must always be a 4-tuple of ints.""" + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nversion = "0.8.6"\n', encoding="utf-8") + ns = _make_helpers_ns(repo_root=tmp_path) + result = ns["_read_version_from_pyproject"](tmp_path) + assert isinstance(result, tuple), "Must return a tuple" + assert len(result) == 4, "Tuple must have exactly 4 elements" + assert all(isinstance(x, int) for x in result), "All elements must be int" diff --git a/uv.lock b/uv.lock index 83746999a..16a1330ac 100644 --- a/uv.lock +++ b/uv.lock @@ -179,7 +179,7 @@ wheels = [ [[package]] name = "apm-cli" -version = "0.8.5" +version = "0.8.6" source = { editable = "." } dependencies = [ { name = "click" },