Skip to content

composer-update: derive ~min_safe.minor.0 constraints from composer audit data#25

Merged
oxyc merged 1 commit into
masterfrom
feat/minimum-safe-version-constraints
May 19, 2026
Merged

composer-update: derive ~min_safe.minor.0 constraints from composer audit data#25
oxyc merged 1 commit into
masterfrom
feat/minimum-safe-version-constraints

Conversation

@oxyc
Copy link
Copy Markdown
Member

@oxyc oxyc commented May 19, 2026

Summary

Vuln-update PRs have been over-bumping when only a patch was needed:

Package Before After (today) CVE-safe at
wpackagist-plugin/woocommerce 10.5.2 10.7.0 10.5.3
wpackagist-plugin/simple-tags 3.44.0 3.50.0 >3.44.0
phpunit/phpunit 9.6.20 11.5.55 9.6.33

composer update -W <pkg> and composer require -W <pkg> were both running unconstrained, so composer was free to pick the highest version inside the project's existing ^X.Y constraint — bumping minor or even major versions when a same-minor patch would have fixed the CVE.

How it works

composer-update gains an optional vulns_json input — the JSON array of vulnerability records produced by parsing composer audit --format=json (shape: {package, affected, ...}). A small PHP helper at composer-update/scripts/compute-min-safe-constraints.php derives a tight ~X.Y.Z constraint per package:

  • <X.Y.Z~X.Y.Z (simple exclusive bound — >=X.Y.Z, <X.(Y+1))
  • <=X.Y.Z~X.Y.(Z+1) (heuristic next-patch; if no such version exists composer fails to resolve and the package is reported as untouched, which is preferable to a silent over-bump)
  • Multi-range like >=9.0.0,<9.6.33|>=10.0.0,<10.5.62|... → finds the range that contains the currently locked version, uses its upper bound

build_pkg_arg() then attaches :constraint to each name when invoking composer, so the update/require calls see e.g. wpackagist-plugin/woocommerce:~10.5.3 instead of the bare name. composer still picks the highest matching version, but the tilde-3-component form caps that at patch-level within the safe-version minor.

Per-project config

None required. The vulnerability-scan reusable workflow already parses composer audit JSON for the PR body — it now also forwards that parsed JSON to composer-update via the new input. Consumer repos continue to call the workflow exactly as before.

Side benefit: audit-blocked transitives

When a CVE's safe range is entirely behind security advisories (e.g. gorans where wordpress-no-content 6.8+ are audit-blocked), the tight constraint will fail to resolve, the per-package retry will leave the lock unchanged, and the package will be reported as untouched — instead of getting a courtesy bump from another package's -W update.

Verification

Ran the PHP helper against the 6 packages flagged in holmasto #92 using their current locked versions; all produced the expected ~X.Y.Z form:

{
  "wpackagist-plugin/woocommerce": "~10.5.3",
  "wpackagist-plugin/wordpress-seo": "~27.1.2",
  "phpunit/phpunit": "~9.6.33",
  "roots/wordpress-no-content": "~6.9.2",
  "wpackagist-plugin/simple-tags": "~3.44.1",
  "generoi/gravityforms": "~2.9.31"
}

Test plan

  • Re-trigger the vuln scan on holmasto and confirm woocommerce lands on 10.5.x (not 10.7.x), wordpress-seo stays within ~27.1.x, etc.
  • Re-trigger on bcplatformscom and confirm phpunit/phpunit stays within ~9.6.x instead of jumping to ^11.5.
  • Re-trigger on gorans and confirm no courtesy wordpress bump now that the unfixable constraint fails to resolve.

🤖 Generated with Claude Code

…udit data

Vuln-update PRs were over-bumping when only a patch was needed:

  wpackagist-plugin/woocommerce  10.5.2  10.7.0   # CVE fixed in 10.5.3
  wpackagist-plugin/simple-tags  3.44.0  3.50.0   # CVE fixed at >3.44.0
  phpunit/phpunit                9.6.20  11.5.55  # CVE fixed in 9.6.33

`composer update -W <pkg>` and `composer require -W <pkg>` were both
running unconstrained, so composer was free to pick the highest version
inside the project's existing `^X.Y` constraint — bumping minor (or even
major) versions when a same-minor patch would have fixed the CVE.

Fix: composer-update gains a `vulns_json` input — the JSON array of
vulnerability records produced by parsing `composer audit --format=json`
(shape: `{package, affected, ...}`). The action runs a small PHP helper
that:

  - finds the affected-range that contains each package's currently
    locked version
  - derives `~X.Y.Z` from the range's exclusive upper bound (`<X.Y.Z`),
    or `~X.Y.(Z+1)` from an inclusive one (`<=X.Y.Z`) as a heuristic
    next-patch cap
  - emits a JSON map of `{pkg: constraint}` consumed by build_pkg_arg()

build_pkg_arg() then attaches `:constraint` to each package name when
invoking composer, so the update/require calls see
`wpackagist-plugin/woocommerce:~10.5.3` instead of just the bare name.
composer still picks the highest matching version, but the tilde-3-comp
form caps that at patch-level within the safe-version minor.

The vulnerability-scan workflow forwards parsed_vulns.json (already
built for the PR body) as the new input. Consumer repos need no
changes — they continue to call the reusable workflow as before.

Side-effect benefit for the audit-blocked-transitive case
(wordpress-no-content): when the constraint's range is entirely behind
security advisories, composer fails to resolve, the per-package retry
leaves the lock unchanged, and the package is reported as untouched
instead of getting a courtesy bump from another package's update.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@oxyc oxyc merged commit 9e8d2e2 into master May 19, 2026
@oxyc oxyc deleted the feat/minimum-safe-version-constraints branch May 19, 2026 17:53
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