Root cause
Other / insufficient public mechanism detail introduced
test(install-local-bundle): cover apm-format legacy path through install
How AI contributed: Direct introductionPublished May 15, 2026 in
microsoft/apm
Microsoft APM: Windows absolute-path tar member overwrite during legacy-bundle probing in apm install. Sink: src/apm_cli/bundle/local_bundle.py.
Root cause
test(install-local-bundle): cover apm-format legacy path through install
Fix
The fix validates tar member names before legacy-bundle probing and extraction, rejecting Windows drive or UNC paths and normalized traversal or absolute paths before extractall() runs.
AI-assisted fix: GitHub Copilot · Daniel Meppiel
Case facts
@@ -136,6 +136,38 @@ def _looks_like_archive(path: Path) -> bool: return name.endswith(".tar.gz") or name.endswith(".tgz") +def _looks_like_legacy_apm_bundle(path: Path) -> bool:+ """Return ``True`` when *path* is a tarball packed with ``--format apm``.++ Legacy bundles contain ``apm.lock.yaml`` at the bundle root but NO+ ``plugin.json``. This helper extracts the tarball to a temp directory,+ checks for the signal, and cleans up. Returns ``False`` on any I/O+ error (caller should fall through to the generic error message).+ """+ if not (path.is_file() and _looks_like_archive(path)):+ return False+ tmp = Path(tempfile.mkdtemp(prefix="apm-legacy-probe-"))+ try:+ with tarfile.open(path, "r:gz") as tar:+ for member in tar.getmembers():+ if member.issym() or member.islnk():+ return False+ if sys.version_info >= (3, 12):+ tar.extractall(tmp, filter="data")+ else:+ tar.extractall(tmp) # noqa: S202+ # Locate the inner directory (apm pack uses arcname=<bundle-name>)+ root = tmp+ children = [p for p in tmp.iterdir() if p.is_dir()]+ if len(children) == 1:+ root = children[0]+ return (root / "apm.lock.yaml").is_file() and not (root / "plugin.json").is_file()+ except (tarfile.TarError, OSError):+ return False+ finally:+ shutil.rmtree(tmp, ignore_errors=True)++ def _find_extracted_root(extract_dir: Path) -> Path | None: """Find the bundle root inside an extracted tarball. @@ -152,10 +152,21 @@ def _looks_like_legacy_apm_bundle(path: Path) -> bool: for member in tar.getmembers(): if member.issym() or member.islnk(): return False+ name = member.name+ if (+ name.startswith("/")+ or PureWindowsPath(name).drive+ or PureWindowsPath(name).is_absolute()+ ):+ return False+ try:+ validate_path_segments(name, context="tar member")+ except PathTraversalError:+ return False if sys.version_info >= (3, 12): tar.extractall(tmp, filter="data") else:- tar.extractall(tmp) # noqa: S202+ tar.extractall(tmp) # noqa: S202 -- validated above # Locate the inner directory (apm pack uses arcname=<bundle-name>) root = tmp children = [p for p in tmp.iterdir() if p.is_dir()]@@ -5,10 +5,11 @@ import tarfile import tempfile from dataclasses import dataclass, field-from pathlib import Path+from pathlib import Path, PureWindowsPath from typing import Dict, List # noqa: F401, UP035 from ..deps.lockfile import LEGACY_LOCKFILE_NAME, LOCKFILE_NAME, LockFile+from ..utils.path_security import PathTraversalError, validate_path_segments @dataclass@@ -64,10 +65,21 @@ def unpack_bundle( with tarfile.open(bundle_path, "r:gz") as tar: # Security: prevent path traversal and special entries for member in tar.getmembers():- if member.name.startswith("/") or ".." in member.name:- raise ValueError(f"Refusing to extract path-traversal entry: {member.name}")+ name = member.name+ if (+ name.startswith("/")+ or PureWindowsPath(name).drive+ or PureWindowsPath(name).is_absolute()+ ):+ raise ValueError(f"Refusing to extract path-traversal entry: {name}")+ try:+ validate_path_segments(name, context="tar member")+ except PathTraversalError:+ raise ValueError(+ f"Refusing to extract path-traversal entry: {name}"+ ) from None if member.issym() or member.islnk():- raise ValueError(f"Refusing to extract symlink/hardlink: {member.name}")+ raise ValueError(f"Refusing to extract symlink/hardlink: {name}") # filter="data" was added in Python 3.12; use it when available if sys.version_info >= (3, 12): tar.extractall(temp_dir, filter="data")AI-assisted change c7b94fccf1e74f14f14dc7263335ad64f01f82995e0e68d398e72a4c28579557 · Fix 3e7b564bedde4ed722a513f265be67a93d657c600343e8d894929721873741d5