{"repro_id":"REPRO-2026-00136","version":8,"title":"Microsoft APM: arbitrary file disclosure via symlink-following on apm install","repro_type":"security","status":"published","severity":"high","description":"`apm-cli` (the `apm` PyPI package) installs APM packages and their remote\ndependencies. When it processes a package it enumerates that package's files\nwith bare `Path.glob()` / `Path.rglob()` and reads them with `read_text()`.\nAll three operations **transparently follow symbolic links**.\n\nA malicious APM dependency can commit a symlink inside its package payload —\nunder `.apm/prompts/` or `.apm/agents/` — that points at an arbitrary absolute\npath on the host (for example `/etc/passwd`). When a victim runs `apm install`\nand that dependency is installed, apm-cli dereferences the symlink: it reads\nthe **target host file's contents** and copies them into the consuming\nproject's installed-package tree.\n\nThe result is **arbitrary file disclosure**: a dependency author can exfiltrate\nany file readable by the user running `apm install` into a file inside the\nvictim's project (where it may then be committed, uploaded, or otherwise\nexposed). No host privileges beyond running `apm install` are needed.","root_cause":"# Root Cause Analysis: CVE-2026-45539\n\n## Summary\n\n`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.\n\n## Impact\n\n- **Package/component affected**: `apm-cli` (PyPI package)\n- **Affected versions**: `0.5.4` → `0.12.4` (inclusive)\n- **Fixed versions**: `0.13.0`\n- **Risk level**: High (CVSS 3.1 base 7.4)\n- **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.\n\n## Root Cause\n\nThe vulnerable code paths are in `PromptIntegrator.find_prompt_files()`, `AgentIntegrator.find_agent_files()`, and the various `copy_*()` methods in `src/apm_cli/integration/`. Specifically:\n\n1. **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.\n\n2. **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.\n\n3. **No containment check** verified whether a resolved file path stayed within the package directory.\n\nThe fix in `v0.13.0` (commit range from `v0.12.4..v0.13.0`, security merge commits `77d1dda` and `f85b9f5`) addresses this by:\n\n- 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.\n- Updating `PromptIntegrator.find_prompt_files()` and `AgentIntegrator.find_agent_files()` to use `find_files_by_glob()` instead of bare `glob()`/`rglob()`.\n- 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.\n\n## Reproduction Steps\n\nThe reproduction script is located at:\n- `repro/reproduction_steps.sh`\n\nWhat the script does:\n1. Creates a scratch workspace under `$ROOT/scratch`.\n2. Creates a malicious local APM package (`malicious_pkg`) containing:\n   - A legitimate `.apm/prompts/legit.prompt.md` and `.apm/agents/legit.agent.md`\n   - A symlink `.apm/prompts/evil.prompt.md -> /path/to/sentinel.txt`\n3. Creates a consumer project with `target: copilot` and an empty `dependencies.apm` list.\n4. Installs `apm-cli==0.12.4` (vulnerable) in a virtualenv.\n5. Runs `apm install ../malicious_pkg` from the consumer project.\n6. Verifies that `evil.prompt.md` appears in `.github/prompts/` with the sentinel file's contents (host file disclosure).\n7. Installs `apm-cli==0.13.0` (fixed) in the same virtualenv.\n8. Resets the consumer project state and re-runs `apm install ../malicious_pkg`.\n9. Verifies that `evil.prompt.md` does **not** appear in `.github/prompts/`.\n\n**Expected evidence of reproduction:**\n- 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.\n- With `apm-cli==0.13.0`: The install log shows `1 prompts integrated`, and `evil.prompt.md` is absent from `.github/prompts/`.\n\n## Evidence\n\nLog files generated by the reproduction script:\n- `logs/vulnerable_install.log` — Output of `apm install` using the vulnerable build.\n- `logs/fixed_install.log` — Output of `apm install` using the fixed build.\n\nKey excerpts from a successful run:\n\n**Vulnerable build (0.12.4):**\n```\n  [+] ../malicious_pkg (local)\n  |-- 2 prompts integrated -> .github/prompts/\n  |-- 1 agents integrated -> .github/agents/\n```\nPost-install file check:\n```\nFILE: .../.github/prompts/evil.prompt.md\nCVE-2026-45539-REPRO-SENTINEL-<timestamp>\n```\n\n**Fixed build (0.13.0):**\n```\n  [+] ../malicious_pkg (local)\n  |-- 1 prompts integrated -> .github/prompts/\n  |-- 1 agents integrated -> .github/agents/\n```\nPost-install file check:\n- `evil.prompt.md` is **not** present under `.github/prompts/`.\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `apm-cli>=0.13.0`.\n2. **Audit existing projects** for any `apm_modules/` or installed dependencies that may have already leaked host file contents.\n3. **Review dependency sources** — only install APM packages from trusted registries or verified local bundles.\n4. **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).\n5. **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.\n\n## Additional Notes\n\n- **Idempotency confirmed**: The reproduction script passes two consecutive runs without modification, producing the same observable results each time.\n- **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.\n- **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.\n","ghsa_id":"GHSA-q5pp-gvjg-h7v4","cve_id":"CVE-2026-45539","source_url":"https://github.com/microsoft/apm","package":{"name":"apm","ecosystem":"pip","affected_versions":"0.5.4 through 0.12.4 inclusive","fixed_version":"0.13.0","tested_patched":"0.13.0"},"reproduced_at":"2026-05-22T09:39:21.610645+00:00","duration_secs":1741.770789861679,"tool_calls":212,"turns":152,"handoffs":2,"total_cost_usd":1.60801903,"agent_costs":{"repro":0.47842523999999986,"support":0.0348343,"vuln_variant":1.0947594900000002},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.47842523999999986},"support":{"accounts/fireworks/models/kimi-k2p6":0.0348343},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.0947594900000002}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T09:39:23.602333+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":6016,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":3753,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7732,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":5020,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":4356,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":692,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":4630,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":1985,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1551,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4363,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2856,"category":"other"},{"path":"vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1662,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1583,"category":"other"},{"path":"vuln_variant/source_identity.json","filename":"source_identity.json","size":639,"category":"other"},{"path":"logs/fixed_install.log","filename":"fixed_install.log","size":341,"category":"log"},{"path":"logs/variant_latest_install.log","filename":"variant_latest_install.log","size":333,"category":"log"},{"path":"logs/variant_vulnerable_install.log","filename":"variant_vulnerable_install.log","size":282,"category":"log"},{"path":"logs/vulnerable_install.log","filename":"vulnerable_install.log","size":398,"category":"log"},{"path":"logs/variant_fixed_install.log","filename":"variant_fixed_install.log","size":333,"category":"log"}]}