Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 9 additions & 21 deletions .github/workflows/composer-update-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,24 +21,12 @@ jobs:
steps:
- uses: actions/checkout@v4

# jq, git and bash are preinstalled on ubuntu-latest runners.
- name: Run shell tests
run: |
shopt -s nullglob
tests=(composer-update/tests/*.test.sh)
if [ ${#tests[@]} -eq 0 ]; then
echo "No tests found" >&2
exit 1
fi
fail=0
for t in "${tests[@]}"; do
echo "::group::$t"
if bash "$t"; then
echo "ok: $t"
else
echo "::error::$t failed"
fail=1
fi
echo "::endgroup::"
done
exit $fail
# The PHP helper tests run the real helpers and bootstrap composer/semver
# into a temp vendor dir. jq, git and bash are preinstalled on the runner.
- uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
tools: composer

- name: Run tests
run: chmod +x composer-update/tests/run.sh && composer-update/tests/run.sh
56 changes: 35 additions & 21 deletions composer-update/scripts/compute-min-safe-constraints.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,26 +5,31 @@
*
* Reads a parsed-vulns JSON array (objects with at minimum {package, affected})
* and the project's composer.lock; emits a JSON object mapping
* {package} => {tight ~constraint}
* {package} => {minimum-safe, minor-capped constraint}
* to stdout.
*
* Strategy: for each vuln, find the affected-range that contains the currently
* locked version, then derive a `~X.Y.Z` constraint capping at the minor of
* the smallest safe version. composer's tilde semantics (`~X.Y.Z` =
* `>=X.Y.Z, <X.(Y+1)`) keep the bump within the same minor, preventing the
* common over-bump where a CVE patched in 10.5.3 leaves composer free to
* jump to 10.7.0 inside an existing `^10.x` constraint.
* locked version, then derive a constraint capping at the minor of the
* smallest safe version. The minor cap prevents the common over-bump where a
* CVE patched in 10.5.3 leaves composer free to jump to 10.7.0 inside an
* existing `^10.x` constraint.
*
* Upper-bound parsing:
* <X.Y.Z → ~X.Y.Z (exclusive — the simple case)
* <=X.Y.Z → ~X.Y.(Z+1) (heuristic next-patch cap; if no such
* version is published the constraint
* fails to resolve and composer-update
* reports the package as untouched —
* preferable to over-bumping silently)
* <X.Y.Z → ~X.Y.Z (exclusive: the fix is exactly X.Y.Z;
* `~X.Y.Z` = `>=X.Y.Z, <X.(Y+1).0`)
* <=X.Y.Z → >X.Y.Z,<X.(Y+1).0 (inclusive: the fix is anything strictly
* above the boundary. We can NOT assume the
* next patch X.Y.(Z+1) — packages often ship
* a 4-segment hotfix like X.Y.Z.1, which a
* `~X.Y.(Z+1)` constraint would skip over.
* A strict-greater lower bound matches the
* hotfix while excluding the vulnerable
* boundary itself.)
*
* Packages with no parseable upper bound get no entry and fall through to
* unconstrained behavior in composer-update.
* unconstrained behavior in composer-update. The `~X.Y.Z` and `>X.Y.Z,<…`
* shapes are both understood downstream by lib.sh's loosen_constraint() and
* build_widen_arg().
*
* Usage: php compute-min-safe-constraints.php <vulns.json> <composer.lock>
*/
Expand Down Expand Up @@ -98,19 +103,28 @@
break;
}

// Inclusive upper bound: <=X[.Y[.Z]]. Bump the patch component to
// get a heuristic "first safe" version. If no such version exists
// on Packagist the tight constraint fails to resolve in
// composer-update — which surfaces the package as untouched
// instead of letting composer pick the latest minor.
// Inclusive upper bound: <=X[.Y[.Z]]. The first safe version is
// anything strictly greater than the boundary, capped at the same
// minor. Do NOT assume the fix is the next *patch* (X.Y.(Z+1)):
// packages frequently ship the fix as a 4-segment hotfix like
// X.Y.Z.1 (e.g. wpackagist seo-by-rank-math advisory `<=1.0.271`
// was fixed in 1.0.271.1), which is >X.Y.Z but <X.Y.(Z+1). A
// `~X.Y.(Z+1)` constraint skips right over that hotfix and fails
// to resolve. Emit `>X.Y.Z,<X.(Y+1).0`: a strict-greater lower
// bound (so the vulnerable boundary X.Y.Z itself is excluded)
// capped at the next minor, same as the `<` tilde case.
if (preg_match('/<=\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?\b/', $range, $m)) {
$major = $m[1];
$minor = ($m[2] ?? '') !== '' ? $m[2] : '0';
$patchNext = (int) (($m[3] ?? '') !== '' ? $m[3] : '0') + 1;
$result[$pkg] = "~$major.$minor.$patchNext";
$patch = ($m[3] ?? '') !== '' ? $m[3] : '0';
$minorNext = (int) $minor + 1;
$result[$pkg] = ">$major.$minor.$patch,<$major.$minorNext.0";
break;
}
}
}

echo json_encode($result, JSON_UNESCAPED_SLASHES);
// Cast to object so an empty result encodes as `{}`, not `[]`. composer-update
// string-indexes this map with `jq '.[$pkg]'`, which errors on a JSON array —
// so an empty array would break the consumer under `set -eo pipefail`.
echo json_encode((object) $result, JSON_UNESCAPED_SLASHES);
164 changes: 164 additions & 0 deletions composer-update/scripts/lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
#!/usr/bin/env bash
#
# Helper functions for composer-update/scripts/update.sh.
#
# Sourced by update.sh (and by the unit tests under ../tests). These functions
# are pure-ish: they take args and read a few tempfiles that update.sh writes
# before calling them:
# /tmp/composer-update-constraints.json (build_pkg_arg, build_widen_arg)
# /tmp/composer-update-direct.txt (find_direct_ancestors, expand_args_for)
# /tmp/composer-update-reverse.txt (find_direct_ancestors)
# /tmp/composer-update-vulns.json (is_still_vulnerable)
# and $GITHUB_ACTION_PATH for the PHP helpers.

# Build the composer arg for a single package: just the name if no
# constraint is configured for it, or `name:constraint` (e.g.
# `vendor/pkg:~1.2.3`) when the caller supplied a tight bound.
# Tilde at offset >0 inside a single word isn't subject to shell
# tilde expansion, so the colon-form is safe to interpolate.
# Trailing newline matters: expand_args_for() concatenates this
# with find_direct_ancestors() and pipes the result to `while read`;
# without it, a direct-dep package produces an unterminated line
# that `read` discards and the arg ends up empty. (#27)
build_pkg_arg() {
local pkg="$1"
local c
c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json)
if [ -n "$c" ]; then
printf '%s:%s\n' "$pkg" "$c"
else
printf '%s\n' "$pkg"
fi
}

# Build the composer arg for the WIDEN step (composer require), which
# REPLACES the project's constraint rather than tightening within it.
# Here the package gets a caret `^min_safe` (e.g. `^27.1.2` =
# `>=27.1.2,<28.0.0`) instead of the patch-pinned tight `~min_safe`.
# Why: `composer require` is reached only when the in-constraint
# update already failed — i.e. the fix lives OUTSIDE composer.json's
# current constraint (e.g. project pins `^26.0` but the patched
# release is 27.x). A tight `~27.1.2` would (a) pin to a single patch
# line that often doesn't exist (Yoast went 27.1.1 → 27.2, no 27.1.2)
# and (b) defeat the whole point of widening. The caret spans the
# safe-version's whole major, so composer can land on the real fix
# (27.6) while still not crossing into the next major. Packages with
# no min_safe (no vulns_json) fall back to a bare name — unconstrained
# widening, as before. (#29)
build_widen_arg() {
local pkg="$1"
local c range_re='^>([0-9]+)\.([0-9]+)\.([0-9]+),<'
c=$(jq -r --arg p "$pkg" '.[$p] // ""' /tmp/composer-update-constraints.json)
if [[ "$c" =~ ^~([0-9.]+)$ ]]; then
printf '%s:^%s' "$pkg" "${BASH_REMATCH[1]}"
elif [[ "$c" =~ $range_re ]]; then
# Inclusive-bound range (>X.Y.Z,<X.(Y+1).0 from compute-min-safe-
# constraints). Widen to span the whole major while still excluding the
# vulnerable boundary X.Y.Z. A caret can't express a strict-greater
# lower bound, so emit an explicit `>X.Y.Z,<(X+1).0.0` range.
printf '%s:>%s.%s.%s,<%s.0.0' "$pkg" "${BASH_REMATCH[1]}" "${BASH_REMATCH[2]}" "${BASH_REMATCH[3]}" "$(( ${BASH_REMATCH[1]} + 1 ))"
else
printf '%s' "$pkg"
fi
}

# Find direct-dep ancestor(s) of a package by BFS through the reverse
# map. Returns one ancestor per line. If the package is itself a
# direct dep, returns just that name. (#22, #26)
find_direct_ancestors() {
local target="$1"
local seen="|"
local queue="$target"
local result=""
while [ -n "$queue" ]; do
local current
current=$(echo "$queue" | head -1)
queue=$(echo "$queue" | tail -n +2)
case "$seen" in *"|$current|"*) continue ;; esac
seen="$seen$current|"
if grep -qFx "$current" /tmp/composer-update-direct.txt; then
result="$result $current"
continue
fi
local parents
parents=$(awk -v t="$current" '$1 == t {print $2}' /tmp/composer-update-reverse.txt)
if [ -n "$parents" ]; then
queue=$(printf '%s\n%s' "$queue" "$parents")
fi
done
echo "$result" | tr ' ' '\n' | grep -v '^$' | sort -u || true
}

# Derive a "same-major, not-affected" loose constraint from the tight
# `~X.Y.Z` form: `>=X.Y.Z, <(X+1).0.0`. Used as a fallback when the
# tight retry matches no published version (e.g. CVE upper bound
# `<=6.6.3` heuristic'd to `~6.6.4`, but WordPress never tagged
# 6.6.4 — the next release is 6.7.0). Lets composer pick the next
# safe version within composer.json's existing constraint rather
# than falling through to widening. (#28)
loosen_constraint() {
local tight="$1"
local range_re='^>([0-9]+)\.([0-9]+)\.([0-9]+),<'
if [[ "$tight" =~ ^~([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
local major="${BASH_REMATCH[1]}"
local minor="${BASH_REMATCH[2]}"
local patch="${BASH_REMATCH[3]}"
printf '>=%s.%s.%s,<%s.0.0' "$major" "$minor" "$patch" "$((major + 1))"
elif [[ "$tight" =~ $range_re ]]; then
# Inclusive-bound range (>X.Y.Z,<X.(Y+1).0): broaden the minor cap to a
# major cap, keeping the strict-greater lower bound that excludes the
# vulnerable boundary X.Y.Z.
local major="${BASH_REMATCH[1]}"
local minor="${BASH_REMATCH[2]}"
local patch="${BASH_REMATCH[3]}"
printf '>%s.%s.%s,<%s.0.0' "$major" "$minor" "$patch" "$((major + 1))"
fi
}

# Safety net for the loose retry: confirm the package's new locked
# version is OUT of every range in its `affected` field before
# accepting the update. composer audit's advisory database normally
# blocks vulnerable versions, but `audit.block-insecure` defaults
# vary by project and we'd rather over-revert than ship a "fix"
# that doesn't fix anything. (#28)
is_still_vulnerable() {
local pkg="$1"
local version="$2"
if [ ! -s /tmp/composer-update-vulns.json ] || [ -z "$version" ]; then
echo no
return
fi
php "$GITHUB_ACTION_PATH/scripts/is-still-vulnerable.php" \
/tmp/composer-update-vulns.json "$pkg" "$version"
}

# Expand a single flagged package into the args we pass to composer:
# `name[:constraint]` for the package itself, plus the names of its
# direct-dep ancestor(s) when the flagged package is a transitive.
#
# Why: `composer update -W X` updates X and X's dependencies (downward),
# but NOT X's reverse-deps. For metapackages like roots/wordpress that
# pin roots/wordpress-no-content at self.version, the parent is locked
# at the same version as the transitive and won't move unless we list
# it explicitly. Without this expansion, updating wordpress-no-content
# within a tight ~constraint fails with "roots/wordpress is locked and
# not requested" and falls through to widening — which then rewrites
# composer.json unnecessarily.
#
# Ancestors get no constraint suffix: we only want them eligible for
# movement within their existing composer.json constraint. (#26)
expand_args_for() {
local pkg="$1"
build_pkg_arg "$pkg"
if ! grep -qFx "$pkg" /tmp/composer-update-direct.txt; then
find_direct_ancestors "$pkg"
fi
}

# Read a package's locked version from a given composer.lock file.
get_lock_version() {
jq -r --arg p "$1" '
((.packages // []) + (."packages-dev" // []))
| map(select(.name == $p)) | first | .version // empty
' "$2"
}
Loading
Loading