#!/usr/bin/env python3 """ patch_diff.py — Download and diff Business Directory Plugin v6.4.21 (vulnerable) vs v6.4.22 (patched) Pinpoints the exact lines changed to fix CVE-2026-2576 Requires: svn OR wget/curl (falls back to direct zip download) """ import os import sys import subprocess import shutil import difflib import urllib.request import zipfile import io from pathlib import Path try: from colorama import Fore, Style, init init(autoreset=True) except ImportError: class Fore: RED=GREEN=YELLOW=CYAN=WHITE=BLUE="" class Style: RESET_ALL="" VULNERABLE = "6.4.21" PATCHED = "6.4.22" SLUG = "business-directory-plugin" # WordPress.org SVN SVN_BASE = f"https://plugins.svn.wordpress.org/{SLUG}/tags" # WordPress.org direct zip ZIP_BASE = f"https://downloads.wordpress.org/plugin/{SLUG}" # Files most likely to contain the fix, in priority order TARGET_FILES = [ "includes/class-db-query-set.php", "views/class-checkout.php", "core/class-payment.php", "includes/helpers/class-payment-query.php", "app/models/class-payment.php", ] WORK_DIR = Path("/tmp/wpbdp_diff") # Try to download using SVN first (faster if available), # otherwise fall back to direct zip download def download_zip(version: str) -> Path: dest = WORK_DIR / version if dest.exists(): print(f" {Fore.CYAN}[cache]{Style.RESET_ALL} Using cached {version}") return dest url = f"{ZIP_BASE}.{version}.zip" print(f" {Fore.BLUE}[dl]{Style.RESET_ALL} Downloading {url} ...") try: with urllib.request.urlopen(url, timeout=30) as resp: data = resp.read() zf = zipfile.ZipFile(io.BytesIO(data)) zf.extractall(WORK_DIR) # Zip extracts to e.g. business-directory-plugin/ extracted = WORK_DIR / SLUG if extracted.exists(): extracted.rename(dest) else: # Some zips use version-suffixed folder for item in WORK_DIR.iterdir(): if item.is_dir() and SLUG in item.name: item.rename(dest) break print(f" {Fore.GREEN}[ok]{Style.RESET_ALL} Extracted to {dest}") return dest except Exception as e: print(f" {Fore.RED}[!]{Style.RESET_ALL} Download failed: {e}") return None # Note: SVN export is faster and cleaner than downloading and extracting zips, # but it requires SVN to be installed and may be blocked in some environments. # This function is not used in the main flow but can be enabled if desired. def diff_file(path_a: Path, path_b: Path) -> list: """Return unified diff lines between two files.""" try: lines_a = path_a.read_text(errors="replace").splitlines(keepends=True) lines_b = path_b.read_text(errors="replace").splitlines(keepends=True) except FileNotFoundError: return [] return list(difflib.unified_diff( lines_a, lines_b, fromfile=str(path_a), tofile=str(path_b), lineterm="", )) # Simple color-coding for diff output def color_diff(lines: list) -> str: out = [] for line in lines: if line.startswith("+++") or line.startswith("---"): out.append(f"{Fore.WHITE}{line}{Style.RESET_ALL}") elif line.startswith("+"): out.append(f"{Fore.GREEN}{line}{Style.RESET_ALL}") elif line.startswith("-"): out.append(f"{Fore.RED}{line}{Style.RESET_ALL}") elif line.startswith("@@"): out.append(f"{Fore.CYAN}{line}{Style.RESET_ALL}") else: out.append(line) return "\n".join(out) # Recursively find all PHP files in a directory, returning relative paths def find_all_php(root: Path) -> list: return sorted(root.rglob("*.php")) # Main execution flow def main(): print(f""" {Fore.YELLOW}══════════════════════════════════════════════════════ CVE-2026-2576 Patch Differ {SLUG} {VULNERABLE} → {PATCHED} ══════════════════════════════════════════════════════{Style.RESET_ALL} """) WORK_DIR.mkdir(parents=True, exist_ok=True) print(f"{Fore.BLUE}[*]{Style.RESET_ALL} Downloading plugin versions...") dir_vuln = download_zip(VULNERABLE) dir_patched = download_zip(PATCHED) if not dir_vuln or not dir_patched: print(f"{Fore.RED}[-]{Style.RESET_ALL} Failed to download one or both versions.") print(" Try manually:") print(f" svn export {SVN_BASE}/{VULNERABLE}/ {WORK_DIR}/{VULNERABLE}/") print(f" svn export {SVN_BASE}/{PATCHED}/ {WORK_DIR}/{PATCHED}/") sys.exit(1) # Targeted diff on known relevant files print(f"\n{Fore.YELLOW}[1] Diffing target files (most likely to contain fix):{Style.RESET_ALL}") found_diffs = [] for rel in TARGET_FILES: fa = dir_vuln / rel fb = dir_patched / rel if not fa.exists() and not fb.exists(): continue diff = diff_file(fa, fb) if diff: print(f"\n{Fore.WHITE}{'═'*60}{Style.RESET_ALL}") print(f"{Fore.YELLOW}FILE: {rel}{Style.RESET_ALL}") print(f"{Fore.WHITE}{'═'*60}{Style.RESET_ALL}") print(color_diff(diff)) found_diffs.append(rel) # Full scan: any changed PHP file print(f"\n{Fore.YELLOW}[2] Full scan — all changed PHP files:{Style.RESET_ALL}") all_vuln = {f.relative_to(dir_vuln): f for f in find_all_php(dir_vuln)} all_patched = {f.relative_to(dir_patched): f for f in find_all_php(dir_patched)} all_keys = set(all_vuln.keys()) | set(all_patched.keys()) changed = [] for rel in sorted(all_keys): fa = all_vuln.get(rel) fb = all_patched.get(rel) if fa is None: print(f" {Fore.GREEN}[NEW]{Style.RESET_ALL} {rel}") changed.append(str(rel)) continue if fb is None: print(f" {Fore.RED}[DEL]{Style.RESET_ALL} {rel}") changed.append(str(rel)) continue if fa.read_bytes() != fb.read_bytes(): changed.append(str(rel)) print(f" {Fore.CYAN}[MOD]{Style.RESET_ALL} {rel}") print(f"\n{Fore.GREEN}[+]{Style.RESET_ALL} {len(changed)} file(s) changed between versions") # Print full diffs for all changed files if "--full" in sys.argv: print(f"\n{Fore.YELLOW}[3] Full diffs (--full mode):{Style.RESET_ALL}") for rel in sorted(all_keys): fa = all_vuln.get(rel) fb = all_patched.get(rel) if fa and fb: diff = diff_file(fa, fb) if diff: print(f"\n{'═'*60}\nFILE: {rel}\n{'═'*60}") print(color_diff(diff)) # Summary and patterns to look for in the diffs print(f""" {Fore.YELLOW}══════════════════════════════════════════════════════ Diff Analysis Complete ══════════════════════════════════════════════════════{Style.RESET_ALL} Vulnerable version : {VULNERABLE} → {dir_vuln} Patched version : {PATCHED} → {dir_patched} Files changed: {len(changed)} {chr(10).join(' ' + f for f in changed[:15])} Look for these patterns in the diff to find the fix: {Fore.GREEN}+ $wpdb->prepare(){Style.RESET_ALL} <= parameterised query added {Fore.GREEN}+ absint(){Style.RESET_ALL} <= integer sanitisation {Fore.GREEN}+ intval(){Style.RESET_ALL} <= integer cast {Fore.GREEN}+ sanitize_text_field(){Style.RESET_ALL} <= string sanitisation {Fore.RED}- $payment_id{Style.RESET_ALL} <= raw variable removed from query Re-run with --full to see complete diffs for all files. """) if __name__ == "__main__": main()