Skip to content

On Composer 1, composer/installers packages (e.g. WordPress plugins) are invisible to the crawler: agent scan skips them as "not installed" and hosted vex attests them without checking the unpatched installed bytes #463

Description

[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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions