Microsoft APM: arbitrary file disclosure via symlink-following on apm install
What's the vulnerability?
apm-cli (the apm PyPI package) installs APM packages and their remote
dependencies. When it processes a package it enumerates that package's files
with bare Path.glob() / Path.rglob() and reads them with read_text().
All three operations transparently follow symbolic links.
A malicious APM dependency can commit a symlink inside its package payload —
under .apm/prompts/ or .apm/agents/ — that points at an arbitrary absolute
path on the host (for example /etc/passwd). When a victim runs apm install
and that dependency is installed, apm-cli dereferences the symlink: it reads
the target host file's contents and copies them into the consuming
project's installed-package tree.
The result is arbitrary file disclosure: a dependency author can exfiltrate
any file readable by the user running apm install into a file inside the
victim's project (where it may then be committed, uploaded, or otherwise
exposed). No host privileges beyond running apm install are needed.
Root Cause Analysis
# Root Cause Analysis: CVE-2026-45539 ## Summary `apm-cli` (the `apm-cli` PyPI package, versions 0.5.4 through 0.12.4) transparently follows symbolic links when enumerating and reading APM package primitive files (`.prompt.md`, `.agent.md`, `.chatmode.md`). A malicious APM dependency can include a symlink under `.apm/prompts/` or `.apm/agents/` that points to an arbitrary absolute host path (e.g. `/etc/passwd`). During `apm install`, the CLI dereferences the symlink, reads the target file's contents with `Path.read_text()`, and copies those contents into the consuming project's installed-package tree (e.g. `.github/prompts/`). This results in arbitrary file disclosure into the victim's project tree. ## Impact - **Package/component affected**: `apm-cli` (PyPI package) - **Affected versions**: `0.5.4` → `0.12.4` (inclusive) - **Fixed versions**: `0.13.0` - **Risk level**: High (CVSS 3.1 base 7.4) - **Consequences**: A malicious dependency author can exfiltrate any file readable by the user running `apm install` into files inside the victim project, where they may be committed, uploaded, or otherwise exposed. ## Root Cause The vulnerable code paths are in `PromptIntegrator.find_prompt_files()`, `AgentIntegrator.find_agent_files()`, and the various `copy_*()` methods in `src/apm_cli/integration/`. Specifically: 1. **File enumeration** used bare `Path.glob()` and `Path.rglob()` without checking whether entries were symlinks. On Python 3.10+, `Path.glob()` transparently follows symlinks, so a symlink inside `.apm/prompts/` matching `*.prompt.md` would be returned as a valid file path. 2. **File reading** used `Path.read_text()` which dereferences symlinks. When `copy_prompt()` or agent copy methods read the source file, they inadvertently read the symlink target's contents. 3. **No containment check** verified whether a resolved file path stayed within the package directory. The fix in `v0.13.0` (commit range from `v0.12.4..v0.13.0`, security merge commits `77d1dda` and `f85b9f5`) addresses this by: - Introducing/refactoring `BaseIntegrator.find_files_by_glob()` to skip symlink entries (`f.is_symlink()`) and hardlinks (`st_nlink > 1`), plus a defense-in-depth `resolve().is_relative_to()` containment guard. - Updating `PromptIntegrator.find_prompt_files()` and `AgentIntegrator.find_agent_files()` to use `find_files_by_glob()` instead of bare `glob()`/`rglob()`. - Adding explicit `source.is_symlink()` checks in `copy_prompt()`, `copy_agent()`, and related agent copy methods that raise `ValueError` if a symlink is somehow passed through. ## Reproduction Steps The reproduction script is located at: - `repro/reproduction_steps.sh` What the script does: 1. Creates a scratch workspace under `$ROOT/scratch`. 2. Creates a malicious local APM package (`malicious_pkg`) containing: - A legitimate `.apm/prompts/legit.prompt.md` and `.apm/agents/legit.agent.md` - A symlink `.apm/prompts/evil.prompt.md -> /path/to/sentinel.txt` 3. Creates a consumer project with `target: copilot` and an empty `dependencies.apm` list. 4. Installs `apm-cli==0.12.4` (vulnerable) in a virtualenv. 5. Runs `apm install ../malicious_pkg` from the consumer project. 6. Verifies that `evil.prompt.md` appears in `.github/prompts/` with the sentinel file's contents (host file disclosure). 7. Installs `apm-cli==0.13.0` (fixed) in the same virtualenv. 8. Resets the consumer project state and re-runs `apm install ../malicious_pkg`. 9. Verifies that `evil.prompt.md` does **not** appear in `.github/prompts/`. **Expected evidence of reproduction:** - With `apm-cli==0.12.4`: The install log shows `2 prompts integrated`, and `scratch/consumer/.github/prompts/evil.prompt.md` exists containing the sentinel string. - With `apm-cli==0.13.0`: The install log shows `1 prompts integrated`, and `evil.prompt.md` is absent from `.github/prompts/`. ## Evidence Log files generated by the reproduction script: - `logs/vulnerable_install.log` — Output of `apm install` using the vulnerable build. - `logs/fixed_install.log` — Output of `apm install` using the fixed build. Key excerpts from a successful run: **Vulnerable build (0.12.4):** ``` [+] ../malicious_pkg (local) |-- 2 prompts integrated -> .github/prompts/ |-- 1 agents integrated -> .github/agents/ ``` Post-install file check: ``` FILE: .../.github/prompts/evil.prompt.md CVE-2026-45539-REPRO-SENTINEL-<timestamp> ``` **Fixed build (0.13.0):** ``` [+] ../malicious_pkg (local) |-- 1 prompts integrated -> .github/prompts/ |-- 1 agents integrated -> .github/agents/ ``` Post-install file check: - `evil.prompt.md` is **not** present under `.github/prompts/`. ## Recommendations / Next Steps 1. **Upgrade immediately** to `apm-cli>=0.13.0`. 2. **Audit existing projects** for any `apm_modules/` or installed dependencies that may have already leaked host file contents. 3. **Review dependency sources** — only install APM packages from trusted registries or verified local bundles. 4. **Add regression tests** that exercise symlink and hardlink rejection during integration (the project already added `tests/unit/integration/test_symlink_rejection.py` in the fix release). 5. **Consider broader symlink policy** — evaluate whether symlinks should be permitted anywhere in the install pipeline (e.g. during download/unpack) or should be stripped at package ingestion time. ## Additional Notes - **Idempotency confirmed**: The reproduction script passes two consecutive runs without modification, producing the same observable results each time. - **Edge cases**: The fix also rejects hardlinks (`st_nlink > 1`) because `Path.resolve()` returns the hardlink's own path, so `is_relative_to()` cannot catch a hardlink to a file outside the package tree. - **Limitations**: The reproduction uses a local path dependency (`../malicious_pkg`). The same vulnerability would be exploitable via remote dependencies if an attacker could publish a malicious APM package containing symlinks in its `.apm/` subtree.
Verify with pruva-verify
Run the Pruva CLI to automatically fetch and execute the reproduction script.
pruva-verify REPRO-2026-00136 pruva-verify GHSA-q5pp-gvjg-h7v4 pruva-verify CVE-2026-45539 curl -fsSL https://pruva.dev/install.sh | sh Or Run Manually
Download the script
curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00136/artifacts/reproduction_steps.sh Make executable
chmod +x reproduction_steps.sh Run the script
./reproduction_steps.sh How Pruva Reproduced This
Watch the AI agent's step-by-step process.
Loading session...
Artifacts
No artifacts available