#!/bin/bash
# =============================================================================
# CVE-2026-54823 — Variant / Bypass analysis for the Widget Options 4.2.4 fix.
#
# Goal: determine whether any MATERIALLY DISTINCT trigger path still reaches
# widgetopts_safe_eval()->eval() for an authenticated CONTRIBUTOR on the
# FIXED (4.2.4) and LATEST (4.2.5) versions, i.e. a bypass of the fix.
#
# Parent CVE: Contributor RCE via the block-renderer REST endpoint
#   POST /wp-json/wp/v2/block-renderer/<block> with a ${'z'}(...)  brace-token
#   payload that the 4.2.3 validator did not block. Fixed in 4.2.4.
#
# Fix layers analysed (4.2.4 == 4.2.5 on the security surface, byte-identical):
#   G1 closed-default trust flag  : widgetopts_safe_eval() returns true for any
#        non-admin who is NOT inside widgetopts_safe_eval_trusted() (whose only
#        callsites consume admin-controlled / DB-backed input).  => contributor
#        never reaches the validator OR eval, regardless of payload.
#   G2 token validator hardening  : now blocks '}' / T_FUNCTION / T_FN before '('.
#   G3 render_block_data sha256 allowlist : scoped to the block-renderer REST
#        route via rest_pre_dispatch; zeroes class.logic not stored in the post.
#   G4 wp_insert_post_data + REST scrub stripper : strips legacy
#        extended_widget_opts.class.logic AND freeform <!--start_widgetopts-->
#        comment logic for non-admins on every save / REST write.
#   G5 preview guard : non-admin preview -> no eval even when trusted.
#
# Candidate variants tested (all would-be RCE if eval is reached):
#   A  original CVE payload              (baseline, same endpoint/sink)
#   B  stripslashes-differential payload (validator/eval parse differential)
#   D  ${'z'}(...) brace variable-call   (the 4.2.3 token bypass)
#   E  array_map($concat,...) callable   (alternate dynamic-call shape)
#   +  freeform <!--start_widgetopts-->  comment injection (alternate data path)
#
# Method: a token-extracted harness loads the REAL plugin functions from each
# version's extracted extras.php / gutenberg-toolbar.php and evaluates whether
# eval() is reached for a simulated contributor (current_user_can=false,
# non-preview, no trust flag). A parse-error probe distinguishes "G1 blocked
# (eval never reached, returns true)" from "eval reached (Throwable caught,
# returns false)".
#
# Exit codes:
#   0 = a variant reproduced on the FIXED/LATEST version (TRUE BYPASS)
#   1 = no bypass; variants either only trigger on the vulnerable 4.2.3 or are
#       fully blocked on 4.2.4/4.2.5 (negative result, script still ran fully)
# =============================================================================
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
VV="$ROOT/vuln_variant"
LOGS="$ROOT/logs/vuln_variant"
WO="$ROOT/artifacts/wo-versions"
mkdir -p "$LOGS"

SUMMARY="$LOGS/variant_summary.log"
: > "$SUMMARY"
log() { echo "[$(date +%T)] $*" | tee -a "$SUMMARY" >&2; }

VULN="4.2.3"
FIXED="4.2.4"
LATEST="4.2.5"

# ---------------------------------------------------------------------------
# Ensure extracted plugin sources exist for each version (extract from zips).
# ---------------------------------------------------------------------------
ensure_extracted() {
  local v="$1"
  local dir="$WO/ext-$v"
  if [ -f "$dir/widget-options/includes/extras.php" ] \
     && [ -f "$dir/widget-options/includes/widgets/gutenberg/gutenberg-toolbar.php" ]; then
    return 0
  fi
  local zip=""
  for z in "$WO/widget-options.${v}.zip" "$ROOT/artifacts/widget-options.${v}.zip"; do
    [ -f "$z" ] && zip="$z" && break
  done
  if [ -z "$zip" ]; then
    log "WARN: no widget-options.${v}.zip found; cannot extract $v"
    return 1
  fi
  rm -rf "$dir"; mkdir -p "$dir"
  unzip -q -o "$zip" -d "$dir" >/dev/null 2>&1 || { log "WARN: unzip $v failed"; return 1; }
  log "Extracted plugin $v from $zip"
}

command -v php >/dev/null 2>&1 || { log "FAIL: php CLI not available"; exit 1; }

for v in "$VULN" "$FIXED" "$LATEST"; do
  ensure_extracted "$v" || true
done

# ---------------------------------------------------------------------------
# Run the variant harness for each version. The harness prints JSON.
# ---------------------------------------------------------------------------
run_harness() {
  local v="$1" out="$LOGS/harness_${v}.log"
  if [ ! -f "$VV/variant_harness.php" ]; then
    log "FAIL: variant_harness.php missing"; return 1
  fi
  if ! php "$VV/variant_harness.php" "$v" >"$out" 2>"$LOGS/harness_${v}.err"; then
    log "FAIL: harness for $v exited non-zero (see $out / .err)"; return 1
  fi
  log "Harness $v -> $out"
}

for v in "$VULN" "$FIXED" "$LATEST"; do
  run_harness "$v" || true
done

# ---------------------------------------------------------------------------
# Classify results with python (jq may be absent).
# ---------------------------------------------------------------------------
python3 - "$LOGS" "$VULN" "$FIXED" "$LATEST" "$SUMMARY" <<'PY'
import json, os, sys
logs, vuln, fixed, latest, summ = sys.argv[1:6]

def load(v):
    p = os.path.join(logs, f"harness_{v}.log")
    try:
        return json.load(open(p))
    except Exception as e:
        return {"_error": str(e)}

R = {v: load(v) for v in (vuln, fixed, latest)}

def eval_reached(res):
    # On vulnerable: "reached_eval_on_vuln" verdict OR parse-error probe == false
    if not res or res.get("_error"): return None
    probe = res.get("parse_error_probe_return")
    if probe == "false":  # eval ran, parse error caught
        return True
    if probe == "true" and res.get("has_closed_default_trust_gate_G1"):
        return False
    vs = res.get("variant", {})
    return any(v.get("verdict", "").startswith("reached_eval_on_vuln") for v in vs.values())

vuln_reached  = eval_reached(R[vuln])
fixed_reached = eval_reached(R[fixed])
lat_reached   = eval_reached(R[latest])

with open(summ, "a") as f:
    f.write("\n---- CLASSIFICATION ----\n")
    f.write(f"vulnerable {vuln}: eval_reached_for_contributor = {vuln_reached}\n")
    f.write(f"fixed      {fixed}: eval_reached_for_contributor = {fixed_reached}\n")
    f.write(f"latest     {latest}: eval_reached_for_contributor = {lat_reached}\n")
    for v in (vuln, fixed, latest):
        r = R[v]
        if r.get("_error"):
            f.write(f"  {v}: ERROR {r['_error']}\n"); continue
        f.write(f"  {v}: G1={r.get('has_closed_default_trust_gate_G1')} "
                f"G2_brace_blocked={r.get('G2_token_validator_blocks_brace_before_paren')} "
                f"G4_freeform_stripper_present={r.get('G4_freeform_stripper_present')} "
                f"probe={r.get('parse_error_probe_return')} "
                f"({r.get('parse_error_probe_meaning')})\n")
        for name, vr in r.get("variant", {}).items():
            f.write(f"    variant {name}: {vr.get('verdict')} (safe_eval={vr.get('safe_eval_return')})\n")
        g4 = r.get("G4_freeform_stripper")
        if isinstance(g4, dict):
            f.write(f"    G4 freeform: logic_removed={g4.get('output_logic_removed')} changed={g4.get('changed')}\n")

# Bypass = a variant reaches eval on the FIXED (or LATEST) version.
bypass = bool(fixed_reached) or bool(lat_reached)
with open(os.path.join(logs, "bypass_flag.txt"), "w") as f:
    f.write("BYPASS_CONFIRMED\n" if bypass else "NO_BYPASS\n")
print("BYPASS_CONFIRMED" if bypass else "NO_BYPASS")
PY

BYPASS_FLAG=$(cat "$LOGS/bypass_flag.txt" 2>/dev/null || echo "NO_BYPASS")
log "Verdict flag: $BYPASS_FLAG"

if [ "$BYPASS_FLAG" = "BYPASS_CONFIRMED" ]; then
  log "CONFIRMED BYPASS: a variant reaches eval on the fixed/latest version."
  exit 0
else
  log "NO BYPASS: all candidate variants are blocked on $FIXED/$LATEST (negative result)."
  log "Variants A/B/D reach eval only on vulnerable $VULN (alternate triggers, not a fix bypass); "
  log "the closed-default trust gate (G1) is the primary blocker on fixed, with G2 (token '}' block) "
  log "and G4 (freeform stripper) as defense in depth."
  exit 1
fi
