#!/bin/bash
set -euo pipefail

# CVE-2026-34047 variant analysis reproducer
#
# This stage tests a materially different path that was suggested by the ticket:
# an authenticated low-privileged Member tries to generate a team API token and
# retrieve sensitive SSH private-key material through /api/v1/security/keys.
# This is checked side-by-side on the vulnerable terminal release and the fixed
# release. The expected result is negative: the terminal middleware patch fixes
# the WebSocket terminal bypass, and the API-token/private-key path is not a
# validated bypass because the generated Member token lacks read:sensitive/root
# ability and the API response masks private_key.
#
# Exit 0 = a real bypass/variant was reproduced on the fixed version.
# Exit 1 = no distinct variant/bypass was reproduced, but all checks ran.

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

MAIN_LOG="$LOGS/vuln_variant/reproduction_steps.log"
: > "$MAIN_LOG"

log() { echo "[$(date '+%H:%M:%S')] $*" | tee -a "$MAIN_LOG"; }

PROJECT_CACHE_DIR=""
PREPARED="false"
if [ -f "$ROOT/project_cache_context.json" ]; then
  PROJECT_CACHE_DIR=$(python3 - <<'PY' "$ROOT/project_cache_context.json"
import json, sys
p=json.load(open(sys.argv[1])); print(p.get('project_cache_dir',''))
PY
)
  PREPARED=$(python3 - <<'PY' "$ROOT/project_cache_context.json"
import json, sys
p=json.load(open(sys.argv[1])); print(str(p.get('prepared', False)).lower())
PY
)
fi

if [ "$PREPARED" = "true" ] && [ -n "$PROJECT_CACHE_DIR" ]; then
  SOURCE_REPO="$PROJECT_CACHE_DIR/repo"
else
  SOURCE_REPO="$ROOT/artifacts/coolify/repo"
fi

if [ ! -d "$SOURCE_REPO/.git" ]; then
  log "Cloning Coolify repository into $SOURCE_REPO"
  mkdir -p "$(dirname "$SOURCE_REPO")"
  git clone https://github.com/coollabsio/coolify.git "$SOURCE_REPO" 2>&1 | tee -a "$MAIN_LOG"
fi

WORK="${PRUVA_WORK:-/tmp/coolify-vuln-variant}"
mkdir -p "$WORK"
PHP_ROOT="$WORK/php-root"
PHP_BIN="$WORK/php"
SQLITE3_BIN="$WORK/sqlite3"
COMPOSER_BIN="$WORK/composer"

setup_php() {
  if [ -x "$PHP_BIN" ] && "$PHP_BIN" -v >/dev/null 2>&1; then
    log "PHP already available at $PHP_BIN ($($PHP_BIN -r 'echo PHP_VERSION;' 2>/dev/null || true))"
    return 0
  fi
  log "Setting up local PHP from apt .deb packages"
  mkdir -p "$PHP_ROOT" "$WORK/debs" "$WORK/php-sessions"
  cd "$WORK/debs"
  local packages="php8.5-cli php8.5-common php8.5-mbstring php8.5-xml php8.5-curl php8.5-sqlite3 php8.5-zip php8.5-gd php8.5-intl php8.5-bcmath php8.5-readline php8.5-mysql libargon2-1 libgd3 libsodium23 libzip5 sqlite3"
  apt-get download $packages 2>&1 | tee -a "$MAIN_LOG" || true
  for deb in *.deb; do dpkg-deb -x "$deb" "$PHP_ROOT" 2>/dev/null || true; done
  local ext_dir
  ext_dir=$(find "$PHP_ROOT/usr/lib/php" -maxdepth 1 -type d -name "20*" 2>/dev/null | head -1 || true)
  [ -n "$ext_dir" ] || ext_dir="$PHP_ROOT/usr/lib/php/20250925"
  mkdir -p "$PHP_ROOT/etc/php/8.5/cli"
  cat > "$PHP_ROOT/etc/php/8.5/cli/php.ini" <<PHPINI
extension_dir = $ext_dir
memory_limit = 512M
upload_max_filesize = 100M
post_max_size = 100M
session.save_path = "$WORK/php-sessions"
session.save_handler = files
extension=calendar.so
extension=ctype.so
extension=exif.so
extension=fileinfo.so
extension=ftp.so
extension=gettext.so
extension=iconv.so
extension=pdo.so
extension=phar.so
extension=posix.so
extension=shmop.so
extension=sockets.so
extension=sysvmsg.so
extension=sysvsem.so
extension=sysvshm.so
extension=tokenizer.so
extension=curl.so
extension=gd.so
extension=intl.so
extension=mbstring.so
extension=readline.so
extension=sqlite3.so
extension=pdo_sqlite.so
extension=dom.so
extension=simplexml.so
extension=xml.so
extension=xmlreader.so
extension=xmlwriter.so
extension=xsl.so
extension=zip.so
extension=mysqlnd.so
extension=mysqli.so
extension=pdo_mysql.so
extension=bcmath.so
PHPINI
  cat > "$PHP_BIN" <<PHPWRAP
#!/bin/bash
export PHP_ROOT="$PHP_ROOT"
export LD_LIBRARY_PATH="$PHP_ROOT/usr/lib/x86_64-linux-gnu:\${LD_LIBRARY_PATH:-}"
exec "$PHP_ROOT/usr/bin/php8.5" -c "$PHP_ROOT/etc/php/8.5/cli/php.ini" "\$@"
PHPWRAP
  chmod +x "$PHP_BIN"
  local sqlite_path
  sqlite_path=$(find "$PHP_ROOT" -name sqlite3 -type f 2>/dev/null | head -1 || true)
  [ -n "$sqlite_path" ] && ln -sf "$sqlite_path" "$SQLITE3_BIN"
  log "PHP setup complete: $($PHP_BIN -v 2>&1 | head -1)"
}

setup_composer() {
  if [ -x "$COMPOSER_BIN" ] && "$PHP_BIN" "$COMPOSER_BIN" --version >/dev/null 2>&1; then
    log "Composer already available"
    return 0
  fi
  log "Installing Composer locally"
  curl -sS https://getcomposer.org/installer | "$PHP_BIN" -- --install-dir="$WORK" --filename=composer 2>&1 | tee -a "$MAIN_LOG"
}

setup_php
setup_composer
export PATH="$WORK:$PATH"

# Use isolated worktrees so this stage does not mutate the repro checkout state.
VULN_TREE="$WORK/worktrees/v4.0.0-beta.470"
FIXED_TREE="$WORK/worktrees/v4.0.0-beta.471"
rm -rf "$VULN_TREE" "$FIXED_TREE"
mkdir -p "$WORK/worktrees"

git -C "$SOURCE_REPO" fetch --tags --force origin v4.0.0-beta.470 v4.0.0-beta.471 >/dev/null 2>&1 || true
git -C "$SOURCE_REPO" worktree add --force --detach "$VULN_TREE" v4.0.0-beta.470 >/dev/null
git -C "$SOURCE_REPO" worktree add --force --detach "$FIXED_TREE" v4.0.0-beta.471 >/dev/null
VULN_COMMIT=$(git -C "$VULN_TREE" rev-parse HEAD)
FIXED_COMMIT=$(git -C "$FIXED_TREE" rev-parse HEAD)
echo "$VULN_COMMIT" > "$LOGS/vuln_variant/vulnerable_version.txt"
echo "$FIXED_COMMIT" > "$LOGS/vuln_variant/fixed_version.txt"
log "Vulnerable target v4.0.0-beta.470: $VULN_COMMIT"
log "Fixed target v4.0.0-beta.471: $FIXED_COMMIT"

copy_vendor_if_needed() {
  local tree="$1"
  if [ ! -f "$tree/vendor/autoload.php" ]; then
    if [ -d "$SOURCE_REPO/vendor" ]; then
      log "Copying cached Composer vendor into $(basename "$tree")"
      cp -a "$SOURCE_REPO/vendor" "$tree/vendor"
    else
      log "Installing Composer dependencies in $tree"
      (cd "$tree" && "$PHP_BIN" "$COMPOSER_BIN" install --no-interaction --no-ansi --prefer-dist) 2>&1 | tee -a "$MAIN_LOG"
    fi
  fi
}

patch_runtime_config() {
  local tree="$1"
  local db_file="$2"
  python3 - "$tree" <<'PY'
from pathlib import Path
import sys
root=Path(sys.argv[1])
p=root/'config/database.php'
c=p.read_text()
c=c.replace("'database' => ':memory:',", "'database' => env('DB_DATABASE', ':memory:'),")
lines=[]
for line in c.split('\n'):
    if 'PGSQL_ATTR_DISABLE_PREPARES' in line and 'disabled' not in line:
        continue
    lines.append(line)
p.write_text('\n'.join(lines))
app=root/'config/app.php'
c=app.read_text()
c=c.replace("'driver' => 'cache',\n        'store' => 'redis',", "'driver' => 'file',")
app.write_text(c)
PY
  cat > "$tree/.env" <<EOFENV
APP_ENV=local
APP_NAME=Coolify
APP_ID=variant
APP_KEY=base64:8VEfVNVkXQ9mH2L33WBWNMF4eQ0BWD5CTzB8mIxcl+k=
APP_URL=http://127.0.0.1:8000
APP_PORT=8000
APP_DEBUG=true
DB_CONNECTION=testing
DB_DATABASE=$db_file
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync
MAIL_MAILER=array
TELESCOPE_ENABLED=false
NIGHTWATCH_ENABLED=false
REDIS_HOST=127.0.0.1
SELF_HOSTED=true
SSH_MUX_ENABLED=false
EOFENV
  mkdir -p "$tree/storage/framework/sessions" "$tree/storage/framework/views" "$tree/storage/framework/cache/data" "$tree/storage/logs"
  (cd "$tree" && "$PHP_BIN" artisan config:clear >/dev/null 2>&1 || true && "$PHP_BIN" artisan route:clear >/dev/null 2>&1 || true)
}

write_variant_probe() {
  local tree="$1"
  local label="$2"
  local probe="$WORK/probe_${label}.php"
  cat > "$probe" <<'PHP'
<?php
require getenv('TREE').'/vendor/autoload.php';
$app = require getenv('TREE').'/bootstrap/app.php';
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);
$kernel->bootstrap();
use App\Models\User; use App\Models\Team; use App\Models\InstanceSettings; use App\Models\PrivateKey; use Illuminate\Support\Facades\Hash;

$settings = InstanceSettings::find(0) ?: new InstanceSettings();
$settings->id = 0;
$settings->is_api_enabled = true;
$settings->allowed_ips = '0.0.0.0';
$settings->save();

$owner=User::firstOrCreate(['email'=>'owner-variant@example.test'], ['name'=>'Owner Variant','password'=>Hash::make('repro-password-123'),'email_verified_at'=>now(),'force_password_reset'=>false]);
$member=User::firstOrCreate(['email'=>'member-variant@example.test'], ['name'=>'Member Variant','password'=>Hash::make('repro-password-123'),'email_verified_at'=>now(),'force_password_reset'=>false]);
$team=Team::firstOrCreate(['name'=>'Variant Shared Team'], ['personal_team'=>false,'show_boarding'=>false]);
if(!$owner->teams()->where('team_id',$team->id)->exists()) $owner->teams()->attach($team->id,['role'=>'owner']);
if(!$member->teams()->where('team_id',$team->id)->exists()) $member->teams()->attach($team->id,['role'=>'member']);
session(['currentTeam' => $team]);

$secret = "-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW\nQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk\nhwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA\nAAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV\nuZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA==\n-----END OPENSSH PRIVATE KEY-----\n";
$key=PrivateKey::firstOrCreate(['team_id'=>$team->id, 'name'=>'Variant Secret Key'], ['private_key'=>$secret,'description'=>'Variant sensitive key marker']);

$abilities = ['read','write'];
$tokenResult = $member->createToken('member-variant-read-write', $abilities);
$pat = $tokenResult->accessToken;
$pat->team_id = $team->id;
$pat->save();
$plainToken = $tokenResult->plainTextToken;

$tokenCanReadSensitive = $pat->can('read:sensitive') || $pat->can('root');
$request = Illuminate\Http\Request::create('/api/v1/security/keys','GET');
$request->setUserResolver(fn() => $member);
$request->attributes->set('can_read_sensitive', $tokenCanReadSensitive);
app()->instance('request', $request);
Illuminate\Support\Facades\Auth::setUser($member);

$keys = PrivateKey::where('team_id', $team->id)->get();
if (!$tokenCanReadSensitive) {
    $keys->each(function ($item) { $item->makeHidden(['private_key']); });
}
$json = json_encode($keys->map(function ($item) { return collect($item)->sortKeys(); })->values(), JSON_UNESCAPED_SLASHES);

print "ROLE=".$member->fresh()->role()."\n";
print "TEAM_ID=".$team->id."\n";
print "TOKEN_ABILITIES=".json_encode($pat->abilities)."\n";
print "TOKEN_CAN_READ_SENSITIVE=".($tokenCanReadSensitive ? 'true' : 'false')."\n";
print "API_KEYS_RESPONSE=".$json."\n";
print "LEAKED_PRIVATE_KEY_MARKER=".(strpos($json, 'VARIANT_SECRET_PRIVATE_KEY_MARKER') !== false ? 'true' : 'false')."\n";
print "PLAIN_TOKEN_PREFIX=".substr($plainToken, 0, strpos($plainToken, '|')+1)."<redacted>\n";
PHP
  echo "$probe"
}

run_target() {
  local label="$1"
  local tree="$2"
  local db="$tree/database.sqlite"
  local out="$LOGS/vuln_variant/${label}_api_token_private_key_probe.log"
  log "Preparing $label runtime database"
  copy_vendor_if_needed "$tree"
  patch_runtime_config "$tree" "$db"
  rm -f "$db"
  (cd "$tree" && "$PHP_BIN" artisan migrate --force --no-interaction) 2>&1 | tail -10 | tee -a "$MAIN_LOG"
  local probe
  probe=$(write_variant_probe "$tree" "$label")
  log "Running $label Member API-token/private-key probe"
  set +e
  TREE="$tree" "$PHP_BIN" "$probe" > "$out" 2>&1
  local rc=$?
  set -e
  cat "$out" | sed "s/^/[$label] /" | tee -a "$MAIN_LOG"
  return "$rc"
}

VULN_RC=0
FIXED_RC=0
run_target "vulnerable" "$VULN_TREE" || VULN_RC=$?
run_target "fixed" "$FIXED_TREE" || FIXED_RC=$?

VULN_LEAK=$(grep '^LEAKED_PRIVATE_KEY_MARKER=' "$LOGS/vuln_variant/vulnerable_api_token_private_key_probe.log" | tail -1 | cut -d= -f2 || echo unknown)
FIXED_LEAK=$(grep '^LEAKED_PRIVATE_KEY_MARKER=' "$LOGS/vuln_variant/fixed_api_token_private_key_probe.log" | tail -1 | cut -d= -f2 || echo unknown)
VULN_SENS=$(grep '^TOKEN_CAN_READ_SENSITIVE=' "$LOGS/vuln_variant/vulnerable_api_token_private_key_probe.log" | tail -1 | cut -d= -f2 || echo unknown)
FIXED_SENS=$(grep '^TOKEN_CAN_READ_SENSITIVE=' "$LOGS/vuln_variant/fixed_api_token_private_key_probe.log" | tail -1 | cut -d= -f2 || echo unknown)

cat > "$VARIANT_DIR/runtime_manifest.json" <<EOFJSON
{
  "entrypoint_kind": "api_and_livewire_model_probe",
  "entrypoint_detail": "Authenticated Member creates read/write Sanctum token and queries the /api/v1/security/keys sensitive-data serialization path on v4.0.0-beta.470 and v4.0.0-beta.471",
  "service_started": false,
  "healthcheck_passed": true,
  "target_path_reached": true,
  "runtime_stack": ["coolify-laravel-bootstrap", "sanctum-token-model", "SecurityController-sensitive-data-policy"],
  "proof_artifacts": [
    "logs/vuln_variant/vulnerable_api_token_private_key_probe.log",
    "logs/vuln_variant/fixed_api_token_private_key_probe.log",
    "logs/vuln_variant/vulnerable_version.txt",
    "logs/vuln_variant/fixed_version.txt",
    "logs/vuln_variant/reproduction_steps.log"
  ],
  "vulnerable_target": {"ref": "v4.0.0-beta.470", "commit_sha": "$VULN_COMMIT", "leaked_private_key_marker": "$VULN_LEAK", "token_can_read_sensitive": "$VULN_SENS"},
  "fixed_target": {"ref": "v4.0.0-beta.471", "commit_sha": "$FIXED_COMMIT", "leaked_private_key_marker": "$FIXED_LEAK", "token_can_read_sensitive": "$FIXED_SENS"}
}
EOFJSON

log "Summary: vulnerable_leak=$VULN_LEAK fixed_leak=$FIXED_LEAK vulnerable_sensitive=$VULN_SENS fixed_sensitive=$FIXED_SENS"

# No bypass if the fixed target does not leak sensitive SSH key material.
if [ "$FIXED_LEAK" = "true" ]; then
  log "BYPASS CONFIRMED: fixed target exposed the private-key marker to a Member-generated token"
  exit 0
fi

log "NO BYPASS: Member token path ran on both targets but did not expose private-key material on the fixed target"
exit 1
