Last active
December 3, 2025 18:48
-
-
Save MarkKropf/d83a36184fc0c4b3eed362cdc6fe119e to your computer and use it in GitHub Desktop.
Quick and dirty recursive patch of CVE-2025-55182 for nextjs apps that use pnpm
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| # CVE-2025-55182 / GHSA-9qr9-h5gf-34mp | |
| # Focused patcher for: | |
| # - react-server-dom-parcel / webpack / turbopack (React 19 RSC) | |
| # - react and react-dom 19.0.0 / 19.1.0 / 19.1.1 / 19.2.0 -> fixed React versions | |
| # - next 15.x / 16.0.x to the minimum patched version | |
| # | |
| # Usage: | |
| # ./patch_cve55182.sh [max_parallel] | |
| # | |
| # Default max_parallel = 4 | |
| MAX_PARALLEL="${1:-4}" | |
| echo "=== CVE-2025-55182 auto patcher (React RSC & Next.js) ===" | |
| ROOT="$(pwd)" | |
| echo "Root directory: ${ROOT}" | |
| echo "Max parallel pnpm jobs: ${MAX_PARALLEL}" | |
| echo | |
| # Simple semantic version compare. | |
| # Prints -1 if $1 < $2, 0 if equal, 1 if $1 > $2. | |
| semver_cmp() { | |
| local a="$1" b="$2" | |
| local IFS=. | |
| local ia=($a) ib=($b) | |
| local i va vb | |
| for ((i=0; i<3; i++)); do | |
| va="${ia[i]:-0}" | |
| vb="${ib[i]:-0}" | |
| if ((10#$va < 10#$vb)); then | |
| echo -1 | |
| return | |
| elif ((10#$va > 10#$vb)); then | |
| echo 1 | |
| return | |
| fi | |
| done | |
| echo 0 | |
| } | |
| # Decide if a "next" version is vulnerable and needs patching. | |
| # Input: full version like 15.1.3 | |
| # Output: target patched version or empty if no change | |
| next_target_version() { | |
| local ver="$1" | |
| local core="${ver%%-*}" # strip anything after dash | |
| local major minor patch | |
| IFS=. read major minor patch <<< "${core}" | |
| if [[ -z "${major:-}" || -z "${minor:-}" || -z "${patch:-}" ]]; then | |
| echo "" | |
| return | |
| fi | |
| local key="${major}.${minor}" | |
| local target="" | |
| case "${key}" in | |
| 15.0) target="15.0.5" ;; | |
| 15.1) target="15.1.9" ;; | |
| 15.2) target="15.2.6" ;; | |
| 15.3) target="15.3.6" ;; | |
| 15.4) target="15.4.8" ;; | |
| 15.5) target="15.5.7" ;; | |
| 16.0) target="16.0.7" ;; | |
| *) target="" ;; | |
| esac | |
| [[ -z "${target}" ]] && { echo ""; return; } | |
| local cmp | |
| cmp="$(semver_cmp "${core}" "${target}")" | |
| if [[ "${cmp}" == "-1" ]]; then | |
| echo "${target}" | |
| else | |
| echo "" | |
| fi | |
| } | |
| # Mapping for React RSC packages and React core | |
| react_rsc_target_version() { | |
| local name="$1" ver="$2" | |
| case "${name}" in | |
| react-server-dom-parcel|react-server-dom-webpack|react-server-dom-turbopack) | |
| case "${ver}" in | |
| 19.0.0) echo "19.0.1" ;; | |
| 19.1.0) echo "19.1.2" ;; | |
| 19.1.1) echo "19.1.2" ;; | |
| 19.2.0) echo "19.2.1" ;; | |
| *) echo "" ;; | |
| esac | |
| ;; | |
| *) | |
| echo "" | |
| ;; | |
| esac | |
| } | |
| react_core_target_version() { | |
| local name="$1" ver="$2" | |
| case "${name}" in | |
| react|react-dom) | |
| case "${ver}" in | |
| 19.0.0) echo "19.0.1" ;; | |
| 19.1.0) echo "19.1.2" ;; | |
| 19.1.1) echo "19.1.2" ;; | |
| 19.2.0) echo "19.2.1" ;; | |
| *) echo "" ;; | |
| esac | |
| ;; | |
| *) | |
| echo "" | |
| ;; | |
| esac | |
| } | |
| total_files=0 | |
| patched_files=0 | |
| total_changes=0 | |
| changed_dirs_file="$(mktemp)" | |
| trap 'rm -f "${changed_dirs_file}"' EXIT | |
| echo "Collecting package.json files (git-aware, respects .gitignore)..." | |
| package_files=() | |
| if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then | |
| # Use git to respect .gitignore and .git/info/exclude | |
| while IFS= read -r f; do | |
| # still skip node_modules explicitly for sanity | |
| case "$f" in | |
| *node_modules/*) continue ;; | |
| esac | |
| package_files+=("./$f") | |
| done < <(git ls-files -co --exclude-standard -- '*package.json') | |
| else | |
| # Fallback: no git repo, just use find, but still avoid node_modules | |
| while IFS= read -r f; do | |
| package_files+=("$f") | |
| done < <(find . -type f -name package.json ! -path "*/node_modules/*" 2>/dev/null || true) | |
| fi | |
| total_files="${#package_files[@]}" | |
| if (( total_files == 0 )); then | |
| echo "No package.json files found. Nothing to do." | |
| exit 0 | |
| fi | |
| echo "Found ${total_files} package.json file(s) (excluding .gitignored paths and node_modules)." | |
| echo | |
| file_index=0 | |
| for pkg in "${package_files[@]}"; do | |
| file_index=$((file_index + 1)) | |
| echo | |
| echo "[${file_index}/${total_files}] Checking ${pkg}" | |
| dir="$(cd "$(dirname "${pkg}")" && pwd)" | |
| js_script=' | |
| const fs = require("fs"); | |
| const reactRscMap = { | |
| "react-server-dom-parcel": { | |
| "19.0.0": "19.0.1", | |
| "19.1.0": "19.1.2", | |
| "19.1.1": "19.1.2", | |
| "19.2.0": "19.2.1", | |
| }, | |
| "react-server-dom-webpack": { | |
| "19.0.0": "19.0.1", | |
| "19.1.0": "19.1.2", | |
| "19.1.1": "19.1.2", | |
| "19.2.0": "19.2.1", | |
| }, | |
| "react-server-dom-turbopack": { | |
| "19.0.0": "19.0.1", | |
| "19.1.0": "19.1.2", | |
| "19.1.1": "19.1.2", | |
| "19.2.0": "19.2.1", | |
| }, | |
| }; | |
| const reactCoreMap = { | |
| "react": { | |
| "19.0.0": "19.0.1", | |
| "19.1.0": "19.1.2", | |
| "19.1.1": "19.1.2", | |
| "19.2.0": "19.2.1", | |
| }, | |
| "react-dom": { | |
| "19.0.0": "19.0.1", | |
| "19.1.0": "19.1.2", | |
| "19.1.1": "19.1.2", | |
| "19.2.0": "19.2.1", | |
| }, | |
| }; | |
| function nextTarget(v) { | |
| if (typeof v !== "string") return ""; | |
| const core = v.split("-")[0].trim(); | |
| const parts = core.split("."); | |
| if (parts.length < 3) return ""; | |
| const major = parseInt(parts[0], 10); | |
| const minor = parseInt(parts[1], 10); | |
| const patch = parseInt(parts[2], 10); | |
| if (Number.isNaN(major) || Number.isNaN(minor) || Number.isNaN(patch)) return ""; | |
| const key = `${major}.${minor}`; | |
| const map = { | |
| "15.0": "15.0.5", | |
| "15.1": "15.1.9", | |
| "15.2": "15.2.6", | |
| "15.3": "15.3.6", | |
| "15.4": "15.4.8", | |
| "15.5": "15.5.7", | |
| "16.0": "16.0.7", | |
| }; | |
| const target = map[key]; | |
| if (!target) return ""; | |
| function cmp(a, b) { | |
| const pa = a.split("."); | |
| const pb = b.split("."); | |
| for (let i = 0; i < 3; i++) { | |
| const va = parseInt(pa[i] || "0", 10); | |
| const vb = parseInt(pb[i] || "0", 10); | |
| if (va < vb) return -1; | |
| if (va > vb) return 1; | |
| } | |
| return 0; | |
| } | |
| if (cmp(core, target) < 0) { | |
| return target; | |
| } | |
| return ""; | |
| } | |
| function reactRscTarget(name, version) { | |
| const map = reactRscMap[name]; | |
| if (!map) return ""; | |
| return map[version] || ""; | |
| } | |
| function reactCoreTarget(name, version) { | |
| const map = reactCoreMap[name]; | |
| if (!map) return ""; | |
| return map[version] || ""; | |
| } | |
| function updateBlock(block, blockName, log) { | |
| if (!block || typeof block !== "object") return 0; | |
| let changes = 0; | |
| for (const [name, currentRaw] of Object.entries(block)) { | |
| if (typeof currentRaw !== "string") continue; | |
| const current = currentRaw.trim(); | |
| let prefix = ""; | |
| let bare = current; | |
| const m = current.match(/^([~^])(.*)$/); | |
| if (m) { | |
| prefix = m[1]; | |
| bare = m[2].trim(); | |
| } | |
| let newVersion = ""; | |
| // React RSC packages | |
| const rsc = reactRscTarget(name, bare); | |
| if (rsc) { | |
| newVersion = rsc; | |
| } | |
| // React core (react and react-dom) | |
| if (!newVersion) { | |
| const core = reactCoreTarget(name, bare); | |
| if (core) { | |
| newVersion = core; | |
| } | |
| } | |
| // Next.js | |
| if (!newVersion && name === "next") { | |
| const nTarget = nextTarget(bare); | |
| if (nTarget) { | |
| newVersion = nTarget; | |
| } | |
| } | |
| if (newVersion) { | |
| const newRaw = `${prefix}${newVersion}`; | |
| block[name] = newRaw; | |
| changes++; | |
| log.push(` - ${blockName}: ${name} ${currentRaw} -> ${newRaw}`); | |
| } | |
| } | |
| return changes; | |
| } | |
| const input = fs.readFileSync(0, "utf8"); | |
| let data; | |
| try { | |
| data = JSON.parse(input); | |
| } catch (e) { | |
| console.error(" ! Failed to parse JSON:", String(e)); | |
| process.exit(2); | |
| } | |
| const log = []; | |
| let total = 0; | |
| total += updateBlock(data.dependencies || {}, "dependencies", log); | |
| total += updateBlock(data.devDependencies || {}, "devDependencies", log); | |
| total += updateBlock(data.peerDependencies || {}, "peerDependencies", log); | |
| total += updateBlock(data.optionalDependencies || {}, "optionalDependencies", log); | |
| if (total > 0) { | |
| for (const line of log) { | |
| console.error(line); | |
| } | |
| console.error(` -> Updated ${total} package entr${total === 1 ? "y" : "ies"} in this file.`); | |
| } else { | |
| console.error(" -> No vulnerable versions found in this file."); | |
| } | |
| process.stdout.write(JSON.stringify(data, null, 2)); | |
| ' | |
| log_file="$(mktemp)" | |
| if ! updated_json="$(node -e "${js_script}" < "${pkg}" 2> "${log_file}")"; then | |
| echo " ! Node processing failed for ${pkg}" | |
| cat "${log_file}" || true | |
| rm -f "${log_file}" | |
| continue | |
| fi | |
| cat "${log_file}" || true | |
| if diff -q "${pkg}" - >/dev/null 2>&1 <<< "${updated_json}"; then | |
| echo " (no changes to write)" | |
| rm -f "${log_file}" | |
| continue | |
| fi | |
| printf "%s\n" "${updated_json}" > "${pkg}" | |
| patched_files=$((patched_files + 1)) | |
| changes_in_file="$(grep -E 'Updated [0-9]+ package entr' "${log_file}" | sed 's/.*Updated \([0-9][0-9]*\) package.*/\1/' | tail -n1 || true)" | |
| if [[ -n "${changes_in_file:-}" ]]; then | |
| total_changes=$((total_changes + changes_in_file)) | |
| fi | |
| echo "${dir}" >> "${changed_dirs_file}" | |
| rm -f "${log_file}" | |
| done | |
| changed_dirs=() | |
| if [[ -s "${changed_dirs_file}" ]]; then | |
| while IFS= read -r d; do | |
| changed_dirs+=("$d") | |
| done < <(sort -u "${changed_dirs_file}") | |
| fi | |
| echo | |
| echo "=== Scan summary ===" | |
| echo "Files scanned: ${total_files}" | |
| echo "Files patched: ${patched_files}" | |
| echo "Total changes: ${total_changes}" | |
| echo "Dirs to rebuild: ${#changed_dirs[@]}" | |
| echo | |
| if (( ${#changed_dirs[@]} == 0 )); then | |
| echo "No vulnerable React, React DOM, RSC, or Next.js versions were found. Exiting." | |
| exit 0 | |
| fi | |
| echo "Running pnpm install and pnpm build in patched directories..." | |
| echo | |
| running_jobs=0 | |
| pids=() | |
| run_pnpm_in_dir() { | |
| local d="$1" | |
| ( | |
| set -e | |
| echo | |
| echo "[pnpm] [${d}] Starting..." | |
| cd "${d}" | |
| echo "[pnpm] [${d}] pnpm install" | |
| if ! pnpm install; then | |
| echo "[pnpm] [${d}] pnpm install FAILED" | |
| exit 1 | |
| fi | |
| echo "[pnpm] [${d}] pnpm build" | |
| if ! pnpm build; then | |
| echo "[pnpm] [${d}] pnpm build FAILED" | |
| exit 1 | |
| fi | |
| echo "[pnpm] [${d}] Completed successfully." | |
| ) & | |
| pids+=("$!") | |
| running_jobs=$((running_jobs + 1)) | |
| } | |
| wait_for_any_job() { | |
| while :; do | |
| local i | |
| for i in "${!pids[@]}"; do | |
| local pid="${pids[$i]}" | |
| if ! kill -0 "${pid}" 2>/dev/null; then | |
| unset 'pids[i]' | |
| running_jobs=$((running_jobs - 1)) | |
| return | |
| fi | |
| done | |
| sleep 0.5 | |
| done | |
| } | |
| for d in "${changed_dirs[@]}"; do | |
| echo "[queue] ${d}" | |
| run_pnpm_in_dir "${d}" | |
| while (( running_jobs >= MAX_PARALLEL )); do | |
| wait_for_any_job | |
| done | |
| done | |
| while (( running_jobs > 0 )); do | |
| wait_for_any_job | |
| done | |
| echo | |
| echo "All pnpm jobs finished." | |
| echo "Done." |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment