Skip to content

composer-update: widen with ^min_safe, not the patch-pinned ~min_safe#29

Merged
oxyc merged 1 commit into
masterfrom
fix/composer-update-widen-uses-caret
May 20, 2026
Merged

composer-update: widen with ^min_safe, not the patch-pinned ~min_safe#29
oxyc merged 1 commit into
masterfrom
fix/composer-update-widen-uses-caret

Conversation

@oxyc
Copy link
Copy Markdown
Member

@oxyc oxyc commented May 20, 2026

Summary

Several repos' vuln scans found a fixable advisory but produced no PR. op-hippo is the clearest: wpackagist-plugin/wordpress-seo (CVE-2026-3427, affected <=27.1.1) — 27.6 is published, but the run reported "No updates available".

Root cause

  • composer.json pins wordpress-seo ^26.0; the lock sits at 27.1.1, fix is 27.2+.
  • The update phase tight (~27.1.2) and loose (>=27.1.2,<28) were rejected by composer: "temporary constraint must be a subset of the constraint in your composer.json (^26.0)". Neither 27.x form is a subset of ^26.0, so an in-constraint composer update can't reach the fix. Correct — that's what the widen step (composer require, which replaces the constraint) exists for.
  • But the widen step used build_pkg_arg → the tight ~27.1.2. It ran composer require wordpress-seo:~27.1.2, pinning the 27.1.x patch line. Yoast never shipped 27.1.2 (27.1.1 → 27.2), so it resolved to nothing → no PR. 27.6 was reachable the whole time.

Fix

The widen step now uses a caret ^min_safe (e.g. ^27.1.2 = >=27.1.2,<28.0.0) via a new build_widen_arg helper. Widening is only reached when the fix is outside the current constraint, so it must span the safe version's whole major rather than pin one patch line. composer lands on 27.6, writes ^27.1.2.

The update phase is unchanged (still tight ~ then loose >=,<next-major), so the minimal-bump behavior for in-constraint fixes is preserved. Only the widen arg changes.

Scope

This was masking real fixes on any repo where the patched release lives in a higher minor/major than composer.json's constraint allows. After this, op-hippo (and similar) should produce a proper PR. Genuinely-unfixable cases (abandoned plugins with only dev-trunk, no published fix, Laravel-component mismatches, false positives) still correctly produce no PR.

Test plan

  • Re-run op-hippo; expect a PR bumping wordpress-seo to 27.6 with composer.json ^26.0 → ^27.1.2.
  • Confirm in-constraint patch cases (woocommerce 10.5.x) still take the update path, no needless widening.

🤖 Generated with Claude Code

op-hippo's wordpress-seo (CVE-2026-3427, affected <=27.1.1) went
unfixed even though 27.6 is published and the project allows it. Trace:

  - composer.json pins wpackagist-plugin/wordpress-seo ^26.0, but the
    lock sits at 27.1.1 and the fix is 27.2+.
  - update phase tight (~27.1.2) and loose (>=27.1.2,<28) were both
    rejected by composer: "temporary constraint must be a subset of the
    constraint in your composer.json (^26.0)" — neither 27.x form is a
    subset of ^26.0, so the in-constraint update can't reach the fix.
    Correct: that's what the widen step (composer require, which
    REPLACES the constraint) is for.
  - But the widen step used build_pkg_arg → the tight ~27.1.2. So it
    ran `composer require wordpress-seo:~27.1.2`, which pins to the
    27.1.x patch line. Yoast never shipped 27.1.2 (27.1.1 → 27.2), so
    it resolved to nothing → "No updates available", no PR. 27.6 was
    reachable the whole time.

Fix: the widen step now uses a caret `^min_safe` (e.g. ^27.1.2 =
>=27.1.2,<28.0.0) via a new build_widen_arg helper. Widening is only
reached when the fix is OUTSIDE the current constraint, so it must
span the safe version's whole major rather than pin a single patch
line. composer lands on 27.6, writes ^27.1.2, done.

The update phase keeps using tight/loose (~ and >=,<next-major) — those
correctly stay within composer.json and handle the minimal-bump case.
Only the widen arg changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxyc oxyc merged commit d5d37bc into master May 20, 2026
@oxyc oxyc deleted the fix/composer-update-widen-uses-caret branch May 20, 2026 10:04
oxyc added a commit that referenced this pull request May 29, 2026
… (+ test suite) (#35)

* test(composer-update): unit + integration tests; extract helpers to lib.sh

The composer-update logic had no tests and was fragile (every recent fix —
#20#31 — was a production-only discovery). Add a real test suite and make
the logic testable.

- Extract the helper functions (build_pkg_arg, build_widen_arg,
  find_direct_ancestors, loosen_constraint, is_still_vulnerable,
  expand_args_for, get_lock_version) from update.sh into a sourceable
  scripts/lib.sh. update.sh sources it; behavior is unchanged (the existing
  no-widen integration test still passes).

- Unit tests for the PHP helpers (the fragile version-range logic):
  - compute-min-safe-constraints: exclusive/inclusive bounds, missing version
    components, multi-range selection, no-entry fall-throughs.
  - is-still-vulnerable: in/out of range, multi-range, junk-input fail-safe.

- Unit tests for the bash helpers, each tied to the edge case it guards:
  #27 build_pkg_arg trailing newline, #29 build_widen_arg caret widen,
  #28 loosen_constraint, #22/#26 find_direct_ancestors BFS + expand_args_for.

- Integration tests driving the real update.sh with a fake composer:
  #24 downgrade revert, #21 dev-* revert, #23 per-package retry isolation,
  #20 no-widen honored as a JSON array. (Plus the existing no-widen test.)

- Fix surfaced by the tests: compute-min-safe-constraints emitted `[]` for an
  empty result, but update.sh string-indexes that map with `jq '.[$pkg]'`,
  which errors on a JSON array under `set -eo pipefail`. Encode as `{}`.

- CI: composer-update-tests.yml now sets up PHP and runs tests/run.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(composer-update): inclusive-bound (<=) safe-version derivation skips 4-segment hotfixes

compute-min-safe-constraints derived ~X.Y.(Z+1) for a <=X.Y.Z advisory.
That skips a 4-segment hotfix like X.Y.Z.1, which is >X.Y.Z but
<X.Y.(Z+1): the constraint matched no published version, the update
no-opped, and the vuln went unpatched (no PR opened).

Real case: suomentyokalu / seo-by-rank-math, advisory <=1.0.271, fixed
in 1.0.271.1. The derived ~1.0.272 could not resolve.

Emit >X.Y.Z,<X.(Y+1).0 instead — a strict-greater lower bound that
matches the hotfix while still excluding the vulnerable boundary X.Y.Z,
still minor-capped. Teach loosen_constraint() and build_widen_arg() the
new range shape (major-cap widen/loosen, boundary stays excluded).

Tests: regression case asserting <=1.0.271 -> >1.0.271,<1.1.0 plus
Semver checks that 1.0.271.1 is reachable and 1.0.271 is excluded;
updated existing <= assertions; helper-fn coverage for the range shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: test <test@example.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant