Created
December 4, 2025 00:57
-
-
Save tluyben/f8051d0557025add079e377f1395182f to your computer and use it in GitHub Desktop.
Fix the https://github.com/vercel/next.js/security/advisories/GHSA-9qr9-h5gf-34mp CVE across projects
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
| #!/bin/bash | |
| # | |
| # CVE Fix Script for GHSA-9qr9-h5gf-34mp | |
| # Scans directories for vulnerable Next.js/React versions and patches them | |
| # | |
| # Usage: ./fix-cve.sh <directory> [FIX] | |
| # <directory> - Parent directory to scan for Next.js projects | |
| # FIX - Optional: Actually apply fixes (default is dry-run) | |
| # | |
| # Fixed versions: | |
| # React: 19.0.1, 19.1.2, 19.2.1 | |
| # Next.js: 15.0.5, 15.1.9, 15.2.6, 15.3.6, 15.4.8, 15.5.7, 16.0.7 | |
| # | |
| set -e | |
| # Colors for output | |
| RED='\033[0;31m' | |
| GREEN='\033[0;32m' | |
| YELLOW='\033[1;33m' | |
| BLUE='\033[0;34m' | |
| CYAN='\033[0;36m' | |
| NC='\033[0m' # No Color | |
| # Logging functions | |
| log_info() { | |
| echo -e "${BLUE}[INFO]${NC} $1" | |
| } | |
| log_success() { | |
| echo -e "${GREEN}[OK]${NC} $1" | |
| } | |
| log_warn() { | |
| echo -e "${YELLOW}[WARN]${NC} $1" | |
| } | |
| log_error() { | |
| echo -e "${RED}[ERROR]${NC} $1" | |
| } | |
| log_action() { | |
| echo -e "${CYAN}[ACTION]${NC} $1" | |
| } | |
| log_dry() { | |
| echo -e "${YELLOW}[DRY-RUN]${NC} $1" | |
| } | |
| # Fixed versions mapping (minor version -> fixed patch version) | |
| declare -A NEXTJS_FIXED_VERSIONS=( | |
| ["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" | |
| ) | |
| # React versions corresponding to Next.js minor versions | |
| declare -A REACT_FIXED_VERSIONS=( | |
| ["15.0"]="19.0.1" | |
| ["15.1"]="19.0.1" | |
| ["15.2"]="19.0.1" | |
| ["15.3"]="19.0.1" | |
| ["15.4"]="19.0.1" | |
| ["15.5"]="19.1.2" | |
| ["16.0"]="19.2.1" | |
| ) | |
| # Parse command line arguments | |
| TARGET_DIR="$1" | |
| FIX_MODE="$2" | |
| if [[ -z "$TARGET_DIR" ]]; then | |
| echo "Usage: $0 <directory> [FIX]" | |
| echo "" | |
| echo " <directory> Parent directory to scan for Next.js projects" | |
| echo " FIX Optional: Actually apply fixes (default is dry-run)" | |
| echo "" | |
| echo "Example:" | |
| echo " $0 /path/to/projects # Dry-run mode" | |
| echo " $0 /path/to/projects FIX # Actually fix vulnerabilities" | |
| exit 1 | |
| fi | |
| if [[ ! -d "$TARGET_DIR" ]]; then | |
| log_error "Directory does not exist: $TARGET_DIR" | |
| exit 1 | |
| fi | |
| DRY_RUN=true | |
| if [[ "$FIX_MODE" == "FIX" ]]; then | |
| DRY_RUN=false | |
| log_warn "FIX MODE ENABLED - Changes will be applied!" | |
| else | |
| log_info "DRY-RUN MODE - No changes will be made" | |
| fi | |
| echo "" | |
| echo "========================================" | |
| echo " CVE Fix: GHSA-9qr9-h5gf-34mp" | |
| echo " Scanning: $TARGET_DIR" | |
| echo " Mode: $(if $DRY_RUN; then echo 'DRY-RUN'; else echo 'FIX'; fi)" | |
| echo "========================================" | |
| echo "" | |
| # Extract version from package.json value (handles ^, ~, and exact versions) | |
| extract_version() { | |
| local version_string="$1" | |
| # Remove ^, ~, >=, etc. prefixes | |
| echo "$version_string" | sed 's/^[\^~>=<]*//g' | |
| } | |
| # Get minor version (e.g., "15.4" from "15.4.3") | |
| get_minor_version() { | |
| local version="$1" | |
| echo "$version" | cut -d'.' -f1,2 | |
| } | |
| # Compare versions: returns 0 if $1 < $2, 1 otherwise | |
| version_lt() { | |
| local v1="$1" | |
| local v2="$2" | |
| # Use sort -V for version comparison | |
| if [[ "$(printf '%s\n' "$v1" "$v2" | sort -V | head -n1)" == "$v1" && "$v1" != "$v2" ]]; then | |
| return 0 # v1 < v2 | |
| else | |
| return 1 # v1 >= v2 | |
| fi | |
| } | |
| # Check if a Next.js version is vulnerable | |
| is_vulnerable() { | |
| local version="$1" | |
| local minor_version=$(get_minor_version "$version") | |
| local fixed_version="${NEXTJS_FIXED_VERSIONS[$minor_version]}" | |
| if [[ -z "$fixed_version" ]]; then | |
| # Unknown minor version - can't determine | |
| return 1 | |
| fi | |
| if version_lt "$version" "$fixed_version"; then | |
| return 0 # Vulnerable | |
| else | |
| return 1 # Not vulnerable | |
| fi | |
| } | |
| # Update package.json with new versions | |
| update_package_json() { | |
| local pkg_file="$1" | |
| local next_version="$2" | |
| local react_version="$3" | |
| local eslint_next_version="$4" | |
| log_action "Updating $pkg_file" | |
| # Create backup | |
| cp "$pkg_file" "$pkg_file.bak" | |
| log_info " Created backup: $pkg_file.bak" | |
| # Update next version (handle both exact and ^ versions) | |
| sed -i -E "s/\"next\": *\"[\^~]?[0-9]+\.[0-9]+\.[0-9]+\"/\"next\": \"$next_version\"/" "$pkg_file" | |
| log_info " Updated next to $next_version" | |
| # Update react version | |
| sed -i -E "s/\"react\": *\"[\^~]?[0-9]+\.[0-9]+\.[0-9]+\"/\"react\": \"$react_version\"/" "$pkg_file" | |
| log_info " Updated react to $react_version" | |
| # Update react-dom version | |
| sed -i -E "s/\"react-dom\": *\"[\^~]?[0-9]+\.[0-9]+\.[0-9]+\"/\"react-dom\": \"$react_version\"/" "$pkg_file" | |
| log_info " Updated react-dom to $react_version" | |
| # Update eslint-config-next version | |
| sed -i -E "s/\"eslint-config-next\": *\"[\^~]?[0-9]+\.[0-9]+\.[0-9]+\"/\"eslint-config-next\": \"$eslint_next_version\"/" "$pkg_file" | |
| log_info " Updated eslint-config-next to $eslint_next_version" | |
| } | |
| # Run npm install | |
| run_npm_install() { | |
| local project_dir="$1" | |
| log_action "Running npm install in $project_dir" | |
| # Check if .npmrc exists with legacy-peer-deps | |
| if [[ -f "$project_dir/.npmrc" ]] && grep -q "legacy-peer-deps" "$project_dir/.npmrc"; then | |
| log_info " Using existing .npmrc with legacy-peer-deps" | |
| (cd "$project_dir" && npm install 2>&1) | while read line; do | |
| echo " [npm] $line" | |
| done | |
| else | |
| (cd "$project_dir" && npm install 2>&1) | while read line; do | |
| echo " [npm] $line" | |
| done | |
| fi | |
| if [[ ${PIPESTATUS[0]} -eq 0 ]]; then | |
| log_success "npm install completed" | |
| return 0 | |
| else | |
| log_error "npm install failed" | |
| return 1 | |
| fi | |
| } | |
| # Run npm run check | |
| run_npm_check() { | |
| local project_dir="$1" | |
| log_action "Running npm run check in $project_dir" | |
| local output | |
| output=$(cd "$project_dir" && npm run check 2>&1) | |
| local exit_code=$? | |
| echo "$output" | while read line; do | |
| echo " [tsc] $line" | |
| done | |
| if [[ $exit_code -eq 0 ]]; then | |
| log_success "TypeScript check passed" | |
| return 0 | |
| else | |
| log_error "TypeScript check failed!" | |
| return 1 | |
| fi | |
| } | |
| # Counters | |
| TOTAL_SCANNED=0 | |
| TOTAL_NEXTJS=0 | |
| TOTAL_VULNERABLE=0 | |
| TOTAL_FIXED=0 | |
| TOTAL_ALREADY_PATCHED=0 | |
| TOTAL_ERRORS=0 | |
| # Scan subdirectories | |
| log_info "Scanning subdirectories..." | |
| echo "" | |
| for subdir in "$TARGET_DIR"/*/; do | |
| # Skip if not a directory | |
| [[ ! -d "$subdir" ]] && continue | |
| TOTAL_SCANNED=$((TOTAL_SCANNED + 1)) | |
| dir_name=$(basename "$subdir") | |
| pkg_file="$subdir/package.json" | |
| log_info "Checking: $dir_name" | |
| # Check if package.json exists | |
| if [[ ! -f "$pkg_file" ]]; then | |
| log_info " No package.json found, skipping" | |
| echo "" | |
| continue | |
| fi | |
| # Check if it's a Next.js project | |
| if ! grep -q '"next"' "$pkg_file"; then | |
| log_info " Not a Next.js project, skipping" | |
| echo "" | |
| continue | |
| fi | |
| TOTAL_NEXTJS=$((TOTAL_NEXTJS + 1)) | |
| log_info " Found Next.js project" | |
| # Extract current versions | |
| current_next_raw=$(grep -oP '"next": *"\K[^"]+' "$pkg_file" | head -1) | |
| current_next=$(extract_version "$current_next_raw") | |
| current_react_raw=$(grep -oP '"react": *"\K[^"]+' "$pkg_file" | head -1) | |
| current_react=$(extract_version "$current_react_raw") | |
| current_eslint_raw=$(grep -oP '"eslint-config-next": *"\K[^"]+' "$pkg_file" 2>/dev/null | head -1) | |
| current_eslint=$(extract_version "$current_eslint_raw") | |
| log_info " Current versions:" | |
| log_info " next: $current_next (raw: $current_next_raw)" | |
| log_info " react: $current_react (raw: $current_react_raw)" | |
| log_info " eslint-config-next: ${current_eslint:-'not found'} (raw: ${current_eslint_raw:-'not found'})" | |
| # Get minor version and check if we have a fix for it | |
| minor_version=$(get_minor_version "$current_next") | |
| fixed_next="${NEXTJS_FIXED_VERSIONS[$minor_version]}" | |
| fixed_react="${REACT_FIXED_VERSIONS[$minor_version]}" | |
| if [[ -z "$fixed_next" ]]; then | |
| log_warn " Unknown Next.js minor version: $minor_version - cannot determine vulnerability" | |
| echo "" | |
| continue | |
| fi | |
| log_info " Minor version: $minor_version" | |
| log_info " Fixed versions for this minor: next=$fixed_next, react=$fixed_react" | |
| # Check if vulnerable | |
| if is_vulnerable "$current_next"; then | |
| TOTAL_VULNERABLE=$((TOTAL_VULNERABLE + 1)) | |
| log_warn " VULNERABLE! $current_next < $fixed_next" | |
| if $DRY_RUN; then | |
| log_dry " Would update:" | |
| log_dry " next: $current_next -> $fixed_next" | |
| log_dry " react: $current_react -> $fixed_react" | |
| log_dry " react-dom: $current_react -> $fixed_react" | |
| log_dry " eslint-config-next: ${current_eslint:-'?'} -> $fixed_next" | |
| log_dry " Would run: npm install" | |
| log_dry " Would run: npm run check" | |
| else | |
| log_action "Applying fix..." | |
| # Update package.json | |
| update_package_json "$pkg_file" "$fixed_next" "$fixed_react" "$fixed_next" | |
| # Run npm install | |
| if run_npm_install "$subdir"; then | |
| # Run npm check | |
| if run_npm_check "$subdir"; then | |
| TOTAL_FIXED=$((TOTAL_FIXED + 1)) | |
| log_success "Successfully fixed $dir_name" | |
| else | |
| TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) | |
| log_error "TypeScript check failed for $dir_name" | |
| log_warn "Backup available at: $pkg_file.bak" | |
| fi | |
| else | |
| TOTAL_ERRORS=$((TOTAL_ERRORS + 1)) | |
| log_error "npm install failed for $dir_name" | |
| log_warn "Backup available at: $pkg_file.bak" | |
| fi | |
| fi | |
| else | |
| TOTAL_ALREADY_PATCHED=$((TOTAL_ALREADY_PATCHED + 1)) | |
| log_success " Already patched ($current_next >= $fixed_next)" | |
| fi | |
| echo "" | |
| done | |
| # Summary | |
| echo "" | |
| echo "========================================" | |
| echo " SUMMARY" | |
| echo "========================================" | |
| echo "" | |
| log_info "Directories scanned: $TOTAL_SCANNED" | |
| log_info "Next.js projects found: $TOTAL_NEXTJS" | |
| log_info "Vulnerable projects: $TOTAL_VULNERABLE" | |
| log_info "Already patched: $TOTAL_ALREADY_PATCHED" | |
| if $DRY_RUN; then | |
| echo "" | |
| log_warn "DRY-RUN MODE - No changes were made" | |
| if [[ $TOTAL_VULNERABLE -gt 0 ]]; then | |
| log_info "Run with FIX parameter to apply fixes:" | |
| log_info " $0 $TARGET_DIR FIX" | |
| fi | |
| else | |
| log_success "Successfully fixed: $TOTAL_FIXED" | |
| if [[ $TOTAL_ERRORS -gt 0 ]]; then | |
| log_error "Errors encountered: $TOTAL_ERRORS" | |
| fi | |
| fi | |
| echo "" | |
| echo "========================================" | |
| # Exit with error code if there were errors | |
| if [[ $TOTAL_ERRORS -gt 0 ]]; then | |
| exit 1 | |
| fi | |
| exit 0 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment