# Base stack: the scoring platform itself. Target applications live in compose/ # fragments so that adding a target never touches this file (and never produces a # merge conflict between two people adding targets at the same time). # # docker compose up -d # platform only # docker compose --profile targets up -d # platform + every target # docker compose --profile shopfront up -d # platform + one target # include: - compose/targets.yml # Container names are derived from this, they appear in /etc/hostname inside every # target, and Docker's embedded DNS resolves them on the tool's own network — so this # string is directly observable by the subject of the benchmark. Override it per # deployment with COMPOSE_PROJECT_NAME; the default must stay unremarkable. name: ${COMPOSE_PROJECT_NAME:-platform-edge} networks: # The tool under test is attached here, and to nothing else. It can see the target # applications and the sinkhole, and that is all. `internal: true` here is what makes # blind vulnerabilities measurable rather than merely unreachable: with no route out, # a callback to the tool's own collaborator domain cannot leave, and the sinkhole — # which is the resolver for this network — captures it instead. It also removes the # host gateway from reach, so nothing published on the host's loopback is exposed to # a tool that has just taken RCE on a target. # # Tools needing internet to update templates or signatures must do so in a preparation # step, before the run opens, on a different network. bench-public: name: bench-public internal: true # Fixed addressing because every target fragment must pin `dns: ["10.88.0.5"]` to the # sinkhole. Docker's embedded resolver cannot do the job here: on an internal network # it has no route to forward an external name, so a payload's callback would hang for # the resolver timeout instead of being captured, and blind vulnerabilities would look # unexploitable to every tool. The sinkhole answers everything and forwards the names # that are genuinely internal. ipam: config: - subnet: 10.88.0.0/24 # Collector, database and scoring. `internal: true` means no route out and, more # importantly, no route in from bench-public: a scanner cannot reach the collector # to read the answer key or to forge trigger events. bench-internal: name: bench-internal internal: true # Fixed addressing so the collector can allowlist the sinkhole for its control # endpoints. Targets sit on this network too, and a target is exactly what a tool # takes RCE on, so "internal" alone does not protect the answer key. ipam: config: - subnet: 10.77.0.0/24 volumes: collector-data: services: collector-db: image: postgres:17-alpine environment: POSTGRES_USER: bench POSTGRES_PASSWORD: bench POSTGRES_DB: bench volumes: - collector-data:/var/lib/postgresql/data networks: [bench-internal] healthcheck: test: ["CMD-SHELL", "pg_isready -U bench"] interval: 5s timeout: 3s retries: 20 otel-collector: build: ./platform/collector environment: TELEMETRY_DATABASE_URL: postgresql://bench:bench@collector-db:5432/bench # Run management and event export — the answer key — answer 404 to anyone but the # sinkhole and the container's own loopback, which is how the orchestrator reaches # them (via `docker compose exec`, never a published port). Ingestion stays open to # every target and byte-identical, so a compromised target learns nothing from the # difference. TELEMETRY_CONTROL_CIDRS: "127.0.0.1/32,10.77.0.5/32" TELEMETRY_SYNTHETIC_CIDRS: "10.77.0.5/32" networks: [bench-internal] depends_on: collector-db: {condition: service_healthy} healthcheck: test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://localhost:8900/healthz')"] interval: 5s timeout: 3s retries: 20 # Egress sinkhole. It is the DNS resolver and the only reachable destination for # bench-public, so every outbound lookup or connection a target is tricked into making # lands here, whatever hostname the payload used. Dual-homed because it must answer the # targets and the tool while reporting inward to the collector. resolver: build: ./platform/oob environment: TELEMETRY_ENDPOINT: http://otel-collector:8900 # Owned zone, kept only for statically-tokenised callbacks. The sinkhole is # catch-all: it is the resolver for bench-public, so a tool that points a payload # at its own collaborator domain is captured just the same. Without that, a sealed # network would score every blind SSRF, XXE and command injection as missed by # every tool — a property of our topology, not of the tools. SINKHOLE_ZONE: telemetry-edge.net networks: bench-internal: ipv4_address: 10.77.0.5 bench-public: ipv4_address: 10.88.0.5 # The replay scripts live here read-only because this container is the only place # that has both a route to the applications and an address the collector accepts on # its control endpoints. Running them from the host would fail the allowlist; # running them from a tool-side container would have their traffic scored as a # tool's own exploitation. Read-only, and never mounted into an application. volumes: # `z` because the host enforces SELinux: without a shared label the container # cannot open the directory at all. - ./:/opt/checks:ro,z depends_on: otel-collector: {condition: service_healthy}