[agent] Found by the scheduled Composer bug-hunt routine (ledger #321).
Summary
When a project uses composer/installers extra.installer-paths (WordPress/Bedrock, Drupal, etc.), the package is installed outside vendor/, for example wp-content/plugins/tool/. Composer 2 records that location as install-path in vendor/composer/installed.json, and the crawler uses it. That case passes. Composer 1 doesn't write install-path. In that case the crawler falls back to vendor/<namespace>/<name>, finds nothing there and drops the package. As a result:
scan --mode agent prints [skip] pkg:composer/acme/tool@1.0.0 (not installed; run your package manager's install first …), and exits 0 with the installed plugin still vulnerable.
get <uuid> --mode agent reports 0 of 1 targeted patch applied … 1 not found on disk.
- The skip message is wrong: the package is installed. Running
composer install again, as the message suggests, changes nothing.
Vendored mode on the same project works (it rewires composer.lock, and Composer 1 then mirrors the patched copy into wp-content/plugins/tool). So the defect is limited to discovering the installed tree (scan report, agent apply, and anything else that relies on crawl_all / find_by_purls).
Impact
On Composer 1 (still a supported major; composer-compatibility.yml covers 1.10), agent mode can't patch any package that composer/installers relocates. Many WordPress/Drupal stacks use those types. The user gets exit 0 and an advisory that sends them in a loop.
Repro (Linux, offline fixture + local mock patch API)
# acme/tool 1.0.0 is a local zip (src/Tool.php returns "VULN"), type wordpress-plugin.
# A mock public proxy on 127.0.0.1:18321 serves one free patch for pkg:composer/acme/tool@1.0.0
# (/patch/batch, /patch/by-package, /patch/view/<uuid>, /patch/blob/<hash>).
export COMPOSER_ALLOW_SUPERUSER=1 SOCKET_API_URL=http://127.0.0.1:18321 SOCKET_PROXY_URL=http://127.0.0.1:18321
cat > composer.json <<'J'
{"name":"x/site","repositories":[{"packagist.org":false},
{"type":"package","package":{"name":"composer/installers","version":"1.12.0","type":"composer-plugin",
"require":{"composer-plugin-api":"^1.0 || ^2.0"},"extra":{"class":"Composer\\Installers\\Plugin"},
"autoload":{"psr-4":{"Composer\\Installers\\":"src/Composer/Installers"}},
"source":{"type":"git","url":"https://github.com/composer/installers.git","reference":"d20a64ed3c94748397ff5973488761b22f6d3f19"}}},
{"type":"package","package":{"name":"acme/tool","version":"1.0.0","type":"wordpress-plugin","require":{"composer/installers":"*"},
"autoload":{"psr-4":{"Acme\\":"src/"}},"dist":{"type":"zip","url":"/path/to/tool-1.0.0.zip","shasum":"<sha1>"}}}],
"require":{"acme/tool":"1.0.0","composer/installers":"*"},
"extra":{"installer-paths":{"wp-content/plugins/{$name}/":["type:wordpress-plugin"]}},
"config":{"allow-plugins":{"composer/installers":true}}}
J
php composer-1.10.28.phar install
find . -name Tool.php # ./wp-content/plugins/tool/src/Tool.php
grep -c '"install-path"' vendor/composer/installed.json # 0 on Composer 1, 2 on Composer 2
socket-patch scan --mode agent; echo rc=$?
# [skip] pkg:composer/acme/tool@1.0.0 (not installed; run your package manager's install first, or `socket-patch scan --mode vendored` …)
# rc=0
tail -1 wp-content/plugins/tool/src/Tool.php # still "VULN"
The same script with Composer 2.2.30 or 2.8.12 applies the patch (1 of 1 targeted patch applied, file now PATCHED).
Expected vs actual
- Expected: a package Composer installed is discovered where Composer put it, as the crawler already does for Composer 2 (
composer_crawler.rs:616-619: "for composer/installers targets … install-path is the ONLY record of where the package really lives"). If socket-patch chooses not to support it on Composer 1, it should say so (an explicit refusal or documented limitation, and a non-zero exit) instead of claiming the package is "not installed". Today neither docs/ecosystems.md nor docs/testing/composer-compatibility.md mentions composer/installers.
- Actual: the package is reported as not installed, with exit 0, and stays unpatched.
Matrix (Linux, PHP 8.3, composer/installers 1.12.0)
| Composer |
install-path in installed.json |
agent scan |
get --mode agent |
vendored + reinstall + vex |
| 1.10.28 |
absent |
fail (skip "not installed", rc 0) |
fail ("1 not found on disk") |
pass |
| 2.2.30 |
present |
pass |
— |
— |
| 2.8.12 (installers 1.12 and 2.3) |
present |
pass |
— |
pass |
OS-independent: the logic is pure path resolution.
First bad version
Not a regression: v4.0.0 behaves identically ([skip] … not installed — run your package manager's install first), reproduced twice on each binary.
Suspect code
crates/socket-patch-core/src/crawlers/composer_crawler.rs:621-633 (resolve_package_dir): with no install-path, it always uses vendor/<ns>/<name>. A possible fix is to fall back to the root composer.json's extra.installer-paths (matching type:/vendor:/name rules), plus the default per-type paths composer/installers uses, when the conventional directory is missing. Keep the existing project-root containment check. At minimum, emit a distinct diagnostic.
Tested on main 2463257.
[agent] Found by the scheduled Composer bug-hunt routine (ledger #321).
Summary
When a project uses composer/installers
extra.installer-paths(WordPress/Bedrock, Drupal, etc.), the package is installed outsidevendor/, for examplewp-content/plugins/tool/. Composer 2 records that location asinstall-pathinvendor/composer/installed.json, and the crawler uses it. That case passes. Composer 1 doesn't writeinstall-path. In that case the crawler falls back tovendor/<namespace>/<name>, finds nothing there and drops the package. As a result:scan --mode agentprints[skip] pkg:composer/acme/tool@1.0.0 (not installed; run your package manager's install first …), and exits 0 with the installed plugin still vulnerable.get <uuid> --mode agentreports0 of 1 targeted patch applied … 1 not found on disk.composer installagain, as the message suggests, changes nothing.Vendored mode on the same project works (it rewires
composer.lock, and Composer 1 then mirrors the patched copy intowp-content/plugins/tool). So the defect is limited to discovering the installed tree (scan report, agent apply, and anything else that relies oncrawl_all/find_by_purls).Impact
On Composer 1 (still a supported major;
composer-compatibility.ymlcovers 1.10), agent mode can't patch any package that composer/installers relocates. Many WordPress/Drupal stacks use those types. The user gets exit 0 and an advisory that sends them in a loop.Repro (Linux, offline fixture + local mock patch API)
The same script with Composer 2.2.30 or 2.8.12 applies the patch (
1 of 1 targeted patch applied, file nowPATCHED).Expected vs actual
composer_crawler.rs:616-619: "for composer/installers targets …install-pathis the ONLY record of where the package really lives"). If socket-patch chooses not to support it on Composer 1, it should say so (an explicit refusal or documented limitation, and a non-zero exit) instead of claiming the package is "not installed". Today neither docs/ecosystems.md nor docs/testing/composer-compatibility.md mentions composer/installers.Matrix (Linux, PHP 8.3, composer/installers 1.12.0)
install-pathin installed.jsonscanget --mode agentOS-independent: the logic is pure path resolution.
First bad version
Not a regression: v4.0.0 behaves identically (
[skip] … not installed — run your package manager's install first), reproduced twice on each binary.Suspect code
crates/socket-patch-core/src/crawlers/composer_crawler.rs:621-633(resolve_package_dir): with noinstall-path, it always usesvendor/<ns>/<name>. A possible fix is to fall back to the rootcomposer.json'sextra.installer-paths(matchingtype:/vendor:/name rules), plus the default per-type paths composer/installers uses, when the conventional directory is missing. Keep the existing project-root containment check. At minimum, emit a distinct diagnostic.Tested on main
2463257.