#!/bin/bash
# Reproduction for GHSA-f3w6-w445-fwh9 (activestorage ImageMagick image analysis:
# arbitrary file write, and arbitrary file read with the Disk service).
#
# The script builds a real Rails 8.0.5.1 application that uses Active Storage with the
# :mini_magick variant processor and the Disk service, starts it (Puma) with
# SECRET_KEY_BASE in the process environment, and runs the reported attack chain over
# plain HTTP: an uploaded SVG reaches ImageMagick's MSL coder through the image analyzer
# that Active Storage runs when the file is attached.  MSL then writes an attacker-chosen
# file and reads attacker-chosen files, which are exfiltrated through the attacker's own
# blob.  The same flow is repeated against the fixed activestorage (8.0.5.2, commit
# ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776) as a negative control.
#
# Exit 0 = issue confirmed (vulnerable attempts succeed, fixed attempts fail closed)
# Exit 1 = not reproduced
# Exit 2 = infrastructure failure

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"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1
echo "=== reproduction start $(date -u +%FT%TZ) ==="

FIXED_COMMIT="ea8c40121ce53a3d0255cd4eb6d13bfe3bc01776"
IMAGE="${PRUVA_REPRO_IMAGE:-pruva/repro-f3w6}"
VULN_NAME="pruva-f3w6-vuln"
FIXED_NAME="pruva-f3w6-fixed"
VULN_PORT="${VULN_PORT:-3301}"
FIXED_PORT="${FIXED_PORT:-3302}"
SECRET_KEY_BASE="$(head -c 32 /dev/urandom | od -An -tx1 | tr -d ' \n')"
CANARY_CONTENT="pruva-canary-$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n')"

write_manifest() { # $1=confirmed(true/false) $2=notes
  python3 - "$REPRO_DIR/runtime_manifest.json" "$1" "$2" "$LOGS" <<'PY'
import hashlib, json, os, sys
path, confirmed, notes, logs = sys.argv[1:5]
def sha(p):
    h = hashlib.sha256()
    with open(p, "rb") as fh:
        for chunk in iter(lambda: fh.read(65536), b""):
            h.update(chunk)
    return h.hexdigest()
artifacts, digests = [], {}
for rel in sorted(os.environ.get("PROOF_ARTIFACTS", "").split()):
    p = os.path.join(logs, rel)
    if os.path.isfile(p):
        artifacts.append("logs/" + rel)
        digests["logs/" + rel] = sha(p)
manifest = {
    "entrypoint_kind": "endpoint",
    "entrypoint_detail": "POST /uploads (Active Storage attach) on a Rails 8.0.5.1 app with variant_processor=:mini_magick and the Disk service; ImageMagick analyzer runs at attach time",
    "service_started": bool(os.environ.get("SERVICE_STARTED") == "true"),
    "healthcheck_passed": bool(os.environ.get("HEALTHCHECK_PASSED") == "true"),
    "target_path_reached": bool(confirmed == "true"),
    "runtime_stack": ["ruby-3.4", "rails-8.0.5.1-activestorage", "mini_magick-5.0", "imagemagick-7.1.1-32", "puma", "sqlite3"],
    "target_identity": {
        "repository_url": "https://github.com/rails/rails",
        "commit_sha": os.environ.get("VULN_SHA", ""),
        "target_digest": os.environ.get("TARGET_DIGEST", ""),
        "platform": "linux",
        "architecture": "x86_64",
    },
    "proof_artifacts": artifacts,
    "artifact_sha256": digests,
    "notes": notes,
}
with open(path, "w") as fh:
    json.dump(manifest, fh, indent=2)
print(json.dumps(manifest, indent=2))
PY
}

# ------------------------------------------------------------------ project cache / sources
CACHE_DIR=""
if [ -f "$ROOT/project_cache_context.json" ]; then
  CACHE_DIR="$(python3 -c 'import json,sys;d=json.load(open(sys.argv[1]));print(d.get("project_cache_dir") or "")' "$ROOT/project_cache_context.json")"
fi
[ -n "$CACHE_DIR" ] || CACHE_DIR="$ROOT/artifacts/project-cache"
mkdir -p "$CACHE_DIR"
REPO="$CACHE_DIR/repo"
if [ ! -d "$REPO/.git" ]; then
  echo "--- cloning rails into $REPO"
  git clone --filter=blob:none --no-checkout --single-branch --branch 8-0-stable \
    https://github.com/rails/rails "$REPO"
fi
VULN_DIR="$CACHE_DIR/rails-vuln"
FIXED_DIR="$CACHE_DIR/rails-fixed"
ensure_worktree() {
  local dir="$1" ref="$2"
  if [ ! -f "$dir/activestorage/lib/active_storage.rb" ]; then
    rm -rf "$dir"
    git -C "$REPO" worktree prune >/dev/null 2>&1 || true
    git -C "$REPO" worktree add --detach "$dir" "$ref" >/dev/null
  fi
}
ensure_worktree "$VULN_DIR" "${FIXED_COMMIT}^"
ensure_worktree "$FIXED_DIR" "$FIXED_COMMIT"

VULN_SHA="$(git -C "$REPO" rev-parse "${FIXED_COMMIT}^")"
FIXED_SHA="$(git -C "$REPO" rev-parse "$FIXED_COMMIT")"
export VULN_SHA FIXED_SHA
echo "--- vulnerable checkout: $VULN_SHA"
echo "--- fixed checkout:      $FIXED_SHA"

# Fixed-commit rule: the vulnerable tree must lack the patch, the fixed tree must carry it.
if [ -f "$VULN_DIR/activestorage/lib/active_storage/image_magick/policy.xml" ]; then
  echo "ERROR: vulnerable checkout already contains the fix"; exit 2
fi
if [ ! -f "$FIXED_DIR/activestorage/lib/active_storage/image_magick/policy.xml" ]; then
  echo "ERROR: fixed checkout does not contain the shipped ImageMagick policy"; exit 2
fi
# The fixed checkout is the fix commit itself; the 8.0.5.2 version bump is a later commit
# on the branch, so RAILS_VERSION still reads 8.0.5.1 in the fixed tree.
echo "--- RAILS_VERSION vuln=$(cat "$VULN_DIR/RAILS_VERSION") fixed=$(cat "$FIXED_DIR/RAILS_VERSION") (fix commit; version bump is a later commit)"
if [ ! -f "$FIXED_DIR/activestorage/lib/active_storage/image_magick.rb" ]; then
  echo "ERROR: fixed checkout lacks the fix implementation"; exit 2
fi
if [ -f "$VULN_DIR/activestorage/lib/active_storage/image_magick.rb" ]; then
  echo "ERROR: vulnerable checkout already contains the fix implementation"; exit 2
fi

TARGET_DIGEST="$(printf 'git:https://github.com/rails/rails@%s' "$VULN_SHA" | sha256sum | cut -d' ' -f1)"
export TARGET_DIGEST
echo "--- target digest: $TARGET_DIGEST"

# ------------------------------------------------------------------ build image
if ! docker image inspect "$IMAGE" >/dev/null 2>&1; then
  WORK="$(mktemp -d /tmp/pruva-f3w6-build.XXXXXX)"
  CTX="$WORK/ctx"
  mkdir -p "$CTX/rails-vuln" "$CTX/rails-fixed"
  cp -a "$REPRO_DIR/app" "$CTX/app"
  cp "$REPRO_DIR/docker/Dockerfile" "$CTX/Dockerfile"
  tar -C "$VULN_DIR" --exclude=.git -cf - . | tar -x -C "$CTX/rails-vuln"
  tar -C "$FIXED_DIR" --exclude=.git -cf - . | tar -x -C "$CTX/rails-fixed"
  echo "--- building $IMAGE (ImageMagick 7 source build + two Rails apps)"
  docker build -t "$IMAGE" "$CTX" >"$LOGS/docker_build.log" 2>&1 || {
    echo "ERROR: docker build failed, see logs/docker_build.log"; tail -40 "$LOGS/docker_build.log"; exit 2; }
  rm -rf "$WORK"
else
  echo "--- reusing existing image $IMAGE"
fi
docker run --rm "$IMAGE" magick -version | head -2
docker run --rm "$IMAGE" sh -c 'magick -list format | grep -E " (MSL|MVG|SVG|MSVG) " ; grep -i msl /usr/local/etc/ImageMagick-7/policy.xml || echo "policy: MSL not denied"'

# ------------------------------------------------------------------ start services
start_app() { # $1=name $2=appdir $3=port $4=bundle_path
  local name="$1" appdir="$2" port="$3" bundle_path="$4"
  docker rm -f "$name" >/dev/null 2>&1 || true
  docker run -d --name "$name" \
    -e SECRET_KEY_BASE="$SECRET_KEY_BASE" -e RAILS_ENV=development \
    -e PORT=3000 -e BIND=0.0.0.0 -e BUNDLE_PATH="$bundle_path" \
    -p "127.0.0.1:${port}:3000" \
    -w "$appdir" \
    "$IMAGE" bash -lc "bundle exec puma -b tcp://0.0.0.0:3000 -t 1:5 -e development config.ru"
}
start_app "$VULN_NAME" /app-vuln "$VULN_PORT" /bundle-vuln
start_app "$FIXED_NAME" /app-fixed "$FIXED_PORT" /bundle-fixed

reach_base() { # $1=name $2=port
  local name="$1" port="$2" ip=""
  ip="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$name" 2>/dev/null || true)"
  for _ in $(seq 1 60); do
    for candidate in "http://127.0.0.1:${port}" "http://${ip}:3000"; do
      [ -n "$candidate" ] || continue
      if [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 3 "$candidate/uploads/new" || true)" = "200" ]; then
        echo "$candidate"; return 0
      fi
    done
    sleep 2
  done
  return 1
}

VULN_BASE="$(reach_base "$VULN_NAME" "$VULN_PORT")" || { echo "ERROR: vulnerable app never became healthy"; docker logs "$VULN_NAME" 2>&1 | tail -30; exit 2; }
FIXED_BASE="$(reach_base "$FIXED_NAME" "$FIXED_PORT")" || { echo "ERROR: fixed app never became healthy"; docker logs "$FIXED_NAME" 2>&1 | tail -30; exit 2; }
echo "--- vulnerable app: $VULN_BASE"
echo "--- fixed app:      $FIXED_BASE"
export SERVICE_STARTED=true HEALTHCHECK_PASSED=true

# ------------------------------------------------------------------ attack runs
run_attempt() { # $1=role $2=n $3=container $4=base
  local role="$1" n="$2" cname="$3" base="$4"
  local out="$LOGS/attempts/${role}_${n}"
  mkdir -p "$out"
  docker exec "$cname" sh -c "rm -f /tmp/pruva_arbitrary_write.txt /tmp/pruva_rce_marker; \
      printf '%s\n' '$CANARY_CONTENT' > /srv/pruva_credentials.txt; \
      cp /app-src/app/views/uploads/show.html.erb /app-${role}/app/views/uploads/show.html.erb" >/dev/null
  echo "--- ${role} attempt ${n} (${base})"
  local rc=0
  BASE="$base" OUT="$out" APP_ROOT="/app-${role}" CONTAINER="$cname" \
    CANARY_PATH="/srv/pruva_credentials.txt" CANARY_EXPECT="$CANARY_CONTENT" \
    CANARY_BYTES="$(printf '%s\n' "$CANARY_CONTENT" | wc -c)" \
    WRITE_PATH="/tmp/pruva_arbitrary_write.txt" \
    ERB_PATH="/app-${role}/app/views/uploads/show.html.erb" \
    python3 "$REPRO_DIR/attack/attack.py" >"$out/attack.stdout" 2>&1 || rc=$?
  docker logs "$cname" > "$out/server.log" 2>&1 || true
  echo "    attack exit=${rc}"
  grep -E "SUMMARY|arbitrary write target|exfiltrated|command output|security policy" "$out/attack.stdout" "$out/server.log" 2>/dev/null | sed 's/^/    /' | head -20 || true
  echo "$rc" > "$out/attack.exit"
  return 0
}

run_attempt vuln 1 "$VULN_NAME" "$VULN_BASE"
run_attempt vuln 2 "$VULN_NAME" "$VULN_BASE"
run_attempt fixed 1 "$FIXED_NAME" "$FIXED_BASE"
run_attempt fixed 2 "$FIXED_NAME" "$FIXED_BASE"

# ------------------------------------------------------------------ verdict
verdict() { # $1=role $2=n $3=key
  python3 - "$LOGS/attempts/$1_$2/summary.json" "$3" <<'PY'
import json, sys
try:
    d = json.load(open(sys.argv[1]))
except Exception:
    print("missing"); sys.exit(0)
v = d.get(sys.argv[2])
print("true" if v is True else ("false" if v is False else v))
PY
}

vuln_ok=true; fixed_clear=true
for n in 1 2; do
  for key in arbitrary_write_ok exfil_contains_secret_key_base rce_ok; do
    [ "$(verdict vuln "$n" "$key")" = "true" ] || vuln_ok=false
    [ "$(verdict fixed "$n" "$key")" = "true" ] && fixed_clear=false
  done
done
echo "--- vulnerable attempts produced all effects: $vuln_ok"
echo "--- fixed attempts produced no effect:      $fixed_clear"

PROOF_ARTIFACTS="attempts/vuln_1/attack.stdout attempts/vuln_1/summary.json attempts/vuln_1/attack.log \
attempts/vuln_1/05_payload_env1024.msl attempts/vuln_1/06_payload_env1024.svg \
attempts/vuln_1/10_arbitrary_write.txt attempts/vuln_1/11_exfil_blob_download.bin \
attempts/vuln_1/11b_exfil_canary_download.bin attempts/vuln_1/12_exfil_environ.txt \
attempts/vuln_1/13_response_after_template_write.html attempts/vuln_1/14_rce_marker.txt attempts/vuln_1/server.log \
attempts/vuln_2/attack.stdout attempts/vuln_2/summary.json attempts/vuln_2/attack.log \
attempts/vuln_2/05_payload_env1024.msl attempts/vuln_2/06_payload_env1024.svg \
attempts/vuln_2/10_arbitrary_write.txt attempts/vuln_2/11_exfil_blob_download.bin \
attempts/vuln_2/11b_exfil_canary_download.bin attempts/vuln_2/12_exfil_environ.txt \
attempts/vuln_2/13_response_after_template_write.html attempts/vuln_2/14_rce_marker.txt attempts/vuln_2/server.log \
attempts/fixed_1/attack.stdout attempts/fixed_1/summary.json attempts/fixed_1/attack.log \
attempts/fixed_1/server.log attempts/fixed_2/attack.stdout attempts/fixed_2/summary.json \
attempts/fixed_2/attack.log attempts/fixed_2/server.log"
export PROOF_ARTIFACTS

if [ "$vuln_ok" = true ] && [ "$fixed_clear" = true ]; then
  write_manifest true "attacker-controlled SVG reached ImageMagick MSL through the Active Storage image analyzer at attach time on activestorage 8.0.5.1: arbitrary file write, arbitrary file read of /proc/self/environ and of a planted credential file exfiltrated through the attacker's own blob, and code execution through a template the MSL write replaced. Fixed 8.0.5.2 refuses the MSL coder through the shipped ImageMagick policy."
  echo "=== reproduction confirmed ==="
  exit 0
fi

write_manifest false "vulnerable effects complete: $vuln_ok; fixed version clean: $fixed_clear"
echo "=== reproduction NOT confirmed ==="
exit 1
