From 0b051cacd722c1028553f70c44ed975d1d6dc63b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 20:24:36 +0000 Subject: [PATCH 1/9] Harden prod Docker image: drop toolchain, read-only rootfs, least privilege MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shrink the runtime attack surface and run the prod stack with the smallest possible privileges and a read-only root filesystem. Dockerfile: - Split the Python image into a builder stage (build-essential + cmake, which compile asyncpg/blspy into a self-contained venv) and a clean runtime stage that copies only that venv. The compiler, headers, and apt metadata no longer ship in the runtime image — the single biggest piece of runtime surface. - Copy only the runtime files (main.py, server/, migrations/, static/) instead of the whole repo; scripts/, ops/, tools/, tests/, loadtest.py, and the build/compose files stay out of the image. - Strip every setuid/setgid bit so a compromised process can't reuse a privileged helper to escalate. - Switch the nginx stage to nginxinc/nginx-unprivileged: the master runs as a non-root user (uid 101) on a high port, so the container needs no caps and can run read-only. ops/nginx.conf: listen on 8080 (non-root), route the pid, all temp dirs, and logs to /tmp + stdout/stderr so the root FS can be read-only. docker-compose.prod.yml: - web + nginx: read_only root FS with a small /tmp tmpfs, cap_drop ALL, no-new-privileges; web also gets init: true and an HTTP healthcheck. nginx published port now maps to 8080. - redis (persistence already off), prometheus, postgres_exporter: read_only, cap_drop ALL, no-new-privileges; redis runs as the redis user to avoid needing CAP_SET[UG]ID. - postgres + grafana: no-new-privileges (their privilege-dropping entrypoints and writable state make a full read-only/cap-drop pass a separate change). Verified: both images build with no compiler present and no setuid binaries; web (327MB) and nginx serve 200 on a read-only rootfs with all caps dropped and no-new-privileges; nginx runs as uid 101 and serves /static; prod compose parses. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- Dockerfile | 72 ++++++++++++++++++++++++++++++----------- docker-compose.prod.yml | 59 ++++++++++++++++++++++++++++++++- ops/nginx.conf | 20 +++++++++++- 3 files changed, 130 insertions(+), 21 deletions(-) diff --git a/Dockerfile b/Dockerfile index 7a220226..605c2dc0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -25,33 +25,36 @@ RUN node scripts/build_assets.mjs # ── Stage 2: nginx serving the prebuilt dist straight from disk ─────────────── # Serves everything under /static (sendfile + gzip_static + immutable) and # proxies the rest to the app. Config + dist are baked in (no runtime volumes). -FROM nginx:1.31.2-alpine AS nginx +# The *unprivileged* image runs the master process as a non-root user (uid 101) +# and keeps its pid/temp paths under /tmp, so the container can run with a +# read-only root filesystem and no capabilities (it binds 8080, not 80, so it +# needs no NET_BIND_SERVICE). The companion config (ops/nginx.conf) listens on +# 8080 and routes nginx's pid/temp/log writes to /tmp + stdout/stderr. +FROM nginxinc/nginx-unprivileged:1.31.2-alpine AS nginx COPY ops/nginx.conf /etc/nginx/nginx.conf COPY --from=assets /build/dist/static /srv/dist/static -# ── Stage 3: the Python app (default build target) ──────────────────────────── -# Pinned to a specific patch tag (intentionally NOT a digest) so local dev -# builds still pick up base-image patch updates. The prod *service* images are -# digest-pinned in docker-compose.prod.yml instead. -FROM python:3.12.8-slim-bookworm AS web +# ── Stage 3a: builder — has the C/C++ toolchain, produces a populated venv ───── +# asyncpg and blspy ship C/C++ extensions that the slim base can't compile +# without gcc + cmake. We build them HERE, into a self-contained virtualenv, and +# copy only that venv into the runtime stage below — so the compiler, headers, +# and apt metadata never reach the shipped image (the largest piece of runtime +# attack surface). Pinned to a patch tag (not a digest) so local builds pick up +# base-image patches; prod *service* images are digest-pinned in compose. +FROM python:3.12.8-slim-bookworm AS pybuild -# Don't write .pyc, unbuffered logs, no pip version chatter. -ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 \ +ENV PIP_NO_CACHE_DIR=1 \ PIP_DISABLE_PIP_VERSION_CHECK=1 -WORKDIR /app - -# Install build dependencies for packages with C/C++ extensions (asyncpg, blspy). -# The slim base image lacks gcc and cmake, which are required to compile these -# packages from source. We use --no-install-recommends and clean the apt cache -# to keep the final image size minimal. RUN apt-get update && apt-get install -y --no-install-recommends \ build-essential \ cmake \ && rm -rf /var/lib/apt/lists/* +# Self-contained venv we can lift wholesale into the runtime image. +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + # Install deps first for layer caching. Prefer the fully-pinned lock for # reproducible/prod builds; fall back to requirements.txt if the lock is absent. COPY requirements.txt requirements.lock* ./ @@ -63,7 +66,35 @@ RUN --mount=type=secret,id=proxy_ca \ $(test -s /run/secrets/proxy_ca && echo --cert=/run/secrets/proxy_ca) \ -r $( [ -f requirements.lock ] && echo requirements.lock || echo requirements.txt ) -COPY . . +# ── Stage 3b: the Python app (default build target) ─────────────────────────── +# Clean slim base with NO build toolchain — only the prebuilt venv and the app +# source. Runs as an unprivileged user with a read-only-friendly layout (writes +# nothing at runtime; PYTHONDONTWRITEBYTECODE keeps it from emitting .pyc). +FROM python:3.12.8-slim-bookworm AS web + +# Don't write .pyc, unbuffered logs, no pip version chatter. PATH points at the +# copied venv so `uvicorn`/`python` resolve to the installed dependency set. +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 \ + PATH="/opt/venv/bin:$PATH" + +WORKDIR /app + +# Lift the compiled dependency set from the builder. No gcc/cmake/apt-lists ship. +COPY --from=pybuild /opt/venv /opt/venv + +# Copy ONLY what the server needs at runtime: the entrypoint, the app package, +# the DB migrations applied on startup (server/db.py), and the frontend source +# (served directly only in dev; in prod nginx serves /static and the app serves +# the single baked dist/index.html below). Everything else in the repo — +# scripts/, ops/, tools/, tests/, loadtest.py, build/compose files — stays out +# of the image to shrink the surface and avoid shipping non-runtime files. +COPY main.py ./ +COPY server/ ./server/ +COPY migrations/ ./migrations/ +COPY static/ ./static/ # Bake ONLY the prebuilt index.html. In prod (FRONTEND_DIST=/app/dist, set in # docker-compose.prod.yml) the app serves this single document — so the CSP @@ -71,9 +102,12 @@ COPY . . # /static asset. The app builds no in-process JS cache and mounts no StaticFiles. COPY --from=assets /build/dist/index.html /app/dist/index.html -# Run as an unprivileged user, not root. +# Create an unprivileged user, hand it the app tree, and strip every setuid/ +# setgid bit in the image so a compromised process can't use a leftover +# privileged helper (su, mount, etc.) to escalate. Done as the last root step. RUN useradd --create-home --uid 10001 appuser \ - && chown -R appuser:appuser /app + && chown -R appuser:appuser /app \ + && find / -xdev -perm /6000 -type f -exec chmod a-s {} + 2>/dev/null || true USER appuser EXPOSE 8000 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d2529acb..a2a6bb88 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -94,6 +94,25 @@ services: condition: service_healthy networks: [internal] restart: unless-stopped + # ── Hardening ────────────────────────────────────────────────────────── + # The app writes nothing to disk at runtime and PYTHONDONTWRITEBYTECODE is + # set, so the root FS is read-only; only /tmp is a (small) tmpfs. No Linux + # capabilities are needed (uvicorn binds 8000, a high port, as a non-root + # user) and no-new-privileges blocks any setuid escalation. init: true gives + # uvicorn a real PID 1 for signal handling + zombie reaping. + read_only: true + tmpfs: + - /tmp:size=16m + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] + init: true + healthcheck: + test: ["CMD", "python", "-c", + "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/',timeout=4).status==200 else 1)"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 20s # Single published port + load balancer across the web replicas, and the # static file server for /static. Host cloudflared keeps pointing at @@ -108,16 +127,34 @@ services: context: . target: nginx image: tensies-nginx:latest + # Container listens on 8080 (unprivileged nginx, non-root master). Host + # cloudflared keeps pointing at ${WEB_PUBLISH}, unchanged. ports: - - "${WEB_PUBLISH:-127.0.0.1:8000}:80" + - "${WEB_PUBLISH:-127.0.0.1:8000}:8080" depends_on: [web] networks: [internal] restart: unless-stopped + # Read-only root FS: nginx's pid, temp dirs, and logs are routed to /tmp + + # stdout/stderr in ops/nginx.conf, so /tmp (tmpfs) is the only writable path. + # Non-root master on a high port means no caps and no privilege escalation. + read_only: true + tmpfs: + - /tmp:size=32m + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] redis: image: redis:7.4.1-alpine@sha256:c1e88455c85225310bbea54816e9c3f4b5295815e6dbf80c34d40afc6df28275 + # Run directly as the redis user so the entrypoint never needs to drop privs + # (su-exec would require CAP_SETUID/SETGID, which we drop below). + user: redis command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}", "--save", "", "--appendonly", "no"] + # Persistence is off (no RDB/AOF), so the root FS can be read-only with no + # writable volume; no caps or privilege escalation are needed. + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s @@ -134,6 +171,12 @@ services: POSTGRES_DB: tensies volumes: - pg_data:/var/lib/postgresql/data + # no-new-privileges is always safe. We stop short of read_only/cap_drop here: + # the postgres entrypoint runs as root and uses gosu to drop to the postgres + # user (needs CHOWN/SET[UG]ID/DAC_OVERRIDE/FOWNER) and writes to its data dir + # plus /var/run/postgresql + /tmp — a read-only root FS needs those tmpfs'd + # and the matching cap_add set, which is a separate, test-heavy change. + security_opt: ["no-new-privileges:true"] healthcheck: test: ["CMD-SHELL", "pg_isready -U tensies"] interval: 5s @@ -152,6 +195,11 @@ services: - '--config.file=/etc/prometheus/prometheus.yml' - '--storage.tsdb.path=/prometheus' - '--storage.tsdb.retention.time=365d' + # Reads config read-only, writes only the prom_data volume — so the root FS + # can be read-only. Runs as nobody already; drop all caps + escalation. + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] networks: [internal] restart: unless-stopped @@ -159,6 +207,10 @@ services: image: prometheuscommunity/postgres-exporter:v0.15.0@sha256:386b12d19eab2a37d7cd8ca8b4c7491cc7a830d9581f49af6c98a393da9605e6 environment: DATA_SOURCE_NAME: "postgresql://tensies:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/tensies?sslmode=disable" + # Stateless Go binary that writes nothing — fully locked down. + read_only: true + cap_drop: [ALL] + security_opt: ["no-new-privileges:true"] depends_on: postgres: condition: service_healthy @@ -186,6 +238,11 @@ services: - ./ops/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - ./ops/grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana_data:/var/lib/grafana + # no-new-privileges is safe; grafana writes its sqlite + plugins under + # /var/lib/grafana (the volume) and reads provisioning read-only, so a full + # read_only root FS would need /tmp + a few dirs tmpfs'd — left as a + # follow-up to avoid breaking plugin/datasource provisioning. + security_opt: ["no-new-privileges:true"] # Direct Grafana access on the host. Defaults to loopback (reach it via an # SSH tunnel: `ssh -L 8889:127.0.0.1:8889 user@host`). To expose it on the # box's network set GRAFANA_PUBLISH to e.g. 0.0.0.0:8889 — but Grafana's diff --git a/ops/nginx.conf b/ops/nginx.conf index 36eb48f1..8ab55df8 100644 --- a/ops/nginx.conf +++ b/ops/nginx.conf @@ -14,10 +14,25 @@ # next config load, so reload after scaling: # docker compose -f docker-compose.prod.yml --env-file .env.prod exec nginx nginx -s reload worker_processes auto; + +# Read-only-root-filesystem friendly: keep the pid and (below) all temp paths +# under /tmp (a tmpfs in compose), and send logs to the container's stdout/stderr +# instead of /var/log/nginx — so nothing outside /tmp is ever written at runtime. +pid /tmp/nginx.pid; +error_log /dev/stderr warn; + events { worker_connections 4096; } http { include /etc/nginx/mime.types; # correct Content-Type when serving from disk + + # All scratch/temp dirs under /tmp so the root FS can stay read-only. + client_body_temp_path /tmp/nginx-client-body; + proxy_temp_path /tmp/nginx-proxy; + fastcgi_temp_path /tmp/nginx-fastcgi; + uwsgi_temp_path /tmp/nginx-uwsgi; + scgi_temp_path /tmp/nginx-scgi; + access_log /dev/stdout; # Stock mime.types (nginx 1.27) has no .webmanifest entry, so the PWA # manifest would go out as octet-stream. Map it to the spec content-type. types { application/manifest+json webmanifest; } @@ -40,7 +55,10 @@ http { } server { - listen 80; + # 8080, not 80: the unprivileged nginx image runs as a non-root user that + # can't bind low ports, so the container needs no NET_BIND_SERVICE / caps. + # docker-compose.prod.yml maps the published host port to this. + listen 8080; # The only ingress is the Cloudflare tunnel, so trust Cloudflare's # authoritative client IP and hand the app exactly one X-Forwarded-For From 659c6bf169cdafac2dfa8aa03c939f98053189ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:59:18 +0000 Subject: [PATCH 2/9] Harden prod stack: resource caps, file-backed secrets, noexec tmpfs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds on the image hardening with runtime least-privilege controls. Resource + pid limits (DoS containment): every service now caps cpus, memory, and pids_limit (top-level keys, honored by `docker compose up`), so a runaway or compromised container can't exhaust host CPU/RAM or fork-bomb the box. Secrets as files, not env vars: app-owned secrets (JWT_SECRET, STATS_TOKEN, METRICS_TOKEN) are delivered to the web container as files under /run/secrets via compose `secrets:`, so they no longer appear in `docker inspect` or /proc//environ. server/config.py gains a `_secret()` helper that reads `{NAME}_FILE` (a path) first, falling back to the `{NAME}` env var then a default — an unreadable/empty file falls back rather than silently blanking a secret. The helper is also applied (opt-in) to REDIS_URL, POSTGRES_DSN, GRAFANA_PASS, and DISCORD_BOT_TOKEN so operators can file-back those too; the redis/postgres/grafana passwords stay env-driven for now since those images/URLs consume them directly. tmpfs hardening: the read-only containers' one writable path (/tmp) is mounted noexec,nosuid,nodev so it can't be used to stage and execute a dropped binary. .env.prod.example documents the new secret-file flow (materialise the three files under the gitignored ops/secrets/ before `up`). Verified: prod compose renders valid; the web image serves 200 on a read-only rootfs with a noexec /tmp, pids cap, and JWT loaded from a mounted file (absent from the container env); _secret() file/env/default fallbacks confirmed. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- .env.prod.example | 16 ++++++++-- docker-compose.prod.yml | 66 ++++++++++++++++++++++++++++++++++------- server/config.py | 36 +++++++++++++++++----- 3 files changed, 99 insertions(+), 19 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index f690b36c..1ba1b49a 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -1,7 +1,19 @@ -# Copy to .env.prod and fill in real secrets, then: -# echo -n "$METRICS_TOKEN" > ops/secrets/metrics_token +# Copy to .env.prod and fill in real secrets. The app-owned secrets below are +# delivered to the web container as MOUNTED FILES (not env vars, so they stay out +# of `docker inspect` / /proc//environ). Materialise the files first, then +# bring the stack up: +# set -a; . ./.env.prod; set +a +# mkdir -p ops/secrets +# printf %s "$JWT_SECRET" > ops/secrets/jwt_secret +# printf %s "$STATS_TOKEN" > ops/secrets/stats_token +# printf %s "$METRICS_TOKEN" > ops/secrets/metrics_token # docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build # .env.prod and ops/secrets/ are gitignored — never commit real secrets. +# +# (REDIS_PASSWORD / POSTGRES_PASSWORD / GRAFANA_ADMIN_PASSWORD are still passed via +# env because the redis/postgres/grafana images and connection URLs consume them +# directly; the app's config._secret() also accepts *_FILE for REDIS_URL, +# POSTGRES_DSN and GRAFANA_PASS if you wire those to files later.) # Strong, unique values (e.g. `openssl rand -hex 32`): REDIS_PASSWORD=change-me diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index a2a6bb88..d7d77b40 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -40,8 +40,11 @@ services: GRAFANA_LIVE_URL: http://grafana:3000/api/live/push GRAFANA_USER: admin GRAFANA_PASS: "${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}" - METRICS_TOKEN: "${METRICS_TOKEN:?set METRICS_TOKEN}" - STATS_TOKEN: "${STATS_TOKEN:?set STATS_TOKEN}" + # App-owned secrets are delivered as mounted files (see the `secrets:` block + # below + top-level definitions), NOT env vars — so they don't show up in + # `docker inspect` or /proc//environ. config._secret() reads *_FILE. + METRICS_TOKEN_FILE: /run/secrets/metrics_token + STATS_TOKEN_FILE: /run/secrets/stats_token ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:?set ALLOWED_ORIGINS}" # Public origin → absolute og:image/twitter:image so the iOS share sheet can # render the invite-link preview with the Tensies icon. Optional; relative @@ -62,8 +65,9 @@ services: CSP_EXTRA_SCRIPT_SRC: "${CSP_EXTRA_SCRIPT_SRC:-}" CSP_EXTRA_CONNECT_SRC: "${CSP_EXTRA_CONNECT_SRC:-}" CSP_EXTRA_IMG_SRC: "${CSP_EXTRA_IMG_SRC:-}" - # Signs JWT auth tokens. Generate with: openssl rand -hex 32 - JWT_SECRET: "${JWT_SECRET:?set JWT_SECRET}" + # Signs JWT auth tokens. Delivered as a mounted file (see `secrets:` below), + # not an env var. Generate with: openssl rand -hex 32 + JWT_SECRET_FILE: /run/secrets/jwt_secret # WebAuthn relying party ID — the bare domain users register passkeys on # (no scheme, no port). e.g. "tensies.app" WEBAUTHN_RP_ID: "${WEBAUTHN_RP_ID:?set WEBAUTHN_RP_ID}" @@ -94,18 +98,27 @@ services: condition: service_healthy networks: [internal] restart: unless-stopped + secrets: + - jwt_secret + - stats_token + - metrics_token # ── Hardening ────────────────────────────────────────────────────────── # The app writes nothing to disk at runtime and PYTHONDONTWRITEBYTECODE is - # set, so the root FS is read-only; only /tmp is a (small) tmpfs. No Linux - # capabilities are needed (uvicorn binds 8000, a high port, as a non-root - # user) and no-new-privileges blocks any setuid escalation. init: true gives - # uvicorn a real PID 1 for signal handling + zombie reaping. + # set, so the root FS is read-only; only /tmp is a (small) tmpfs, mounted + # noexec/nosuid/nodev so the one writable path can't be used to stage and run + # a binary. No Linux capabilities are needed (uvicorn binds 8000, a high port, + # as a non-root user) and no-new-privileges blocks any setuid escalation. + # init: true gives uvicorn a real PID 1 for signal handling + zombie reaping. + # Resource + pid caps bound the blast radius of a runaway/compromised worker. read_only: true tmpfs: - - /tmp:size=16m + - /tmp:size=16m,mode=1777,noexec,nosuid,nodev cap_drop: [ALL] security_opt: ["no-new-privileges:true"] init: true + pids_limit: 256 + cpus: 1.0 + mem_limit: 512m healthcheck: test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/',timeout=4).status==200 else 1)"] @@ -139,9 +152,12 @@ services: # Non-root master on a high port means no caps and no privilege escalation. read_only: true tmpfs: - - /tmp:size=32m + - /tmp:size=32m,mode=1777,noexec,nosuid,nodev cap_drop: [ALL] security_opt: ["no-new-privileges:true"] + pids_limit: 256 + cpus: 0.5 + mem_limit: 128m redis: image: redis:7.4.1-alpine@sha256:c1e88455c85225310bbea54816e9c3f4b5295815e6dbf80c34d40afc6df28275 @@ -155,6 +171,9 @@ services: read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] + pids_limit: 128 + cpus: 0.5 + mem_limit: 256m healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 5s @@ -177,6 +196,9 @@ services: # plus /var/run/postgresql + /tmp — a read-only root FS needs those tmpfs'd # and the matching cap_add set, which is a separate, test-heavy change. security_opt: ["no-new-privileges:true"] + pids_limit: 256 + cpus: 1.0 + mem_limit: 1g healthcheck: test: ["CMD-SHELL", "pg_isready -U tensies"] interval: 5s @@ -200,6 +222,9 @@ services: read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] + pids_limit: 128 + cpus: 0.5 + mem_limit: 512m networks: [internal] restart: unless-stopped @@ -211,6 +236,9 @@ services: read_only: true cap_drop: [ALL] security_opt: ["no-new-privileges:true"] + pids_limit: 64 + cpus: 0.25 + mem_limit: 128m depends_on: postgres: condition: service_healthy @@ -243,6 +271,9 @@ services: # read_only root FS would need /tmp + a few dirs tmpfs'd — left as a # follow-up to avoid breaking plugin/datasource provisioning. security_opt: ["no-new-privileges:true"] + pids_limit: 256 + cpus: 1.0 + mem_limit: 512m # Direct Grafana access on the host. Defaults to loopback (reach it via an # SSH tunnel: `ssh -L 8889:127.0.0.1:8889 user@host`). To expose it on the # box's network set GRAFANA_PUBLISH to e.g. 0.0.0.0:8889 — but Grafana's @@ -259,6 +290,21 @@ networks: internal: driver: bridge +# App-owned secrets, mounted into the web container as files under /run/secrets +# (config._secret reads the matching *_FILE env). Delivering them as files keeps +# them out of the container environment (docker inspect / /proc//environ). +# Create the source files before `up` (they're gitignored under ops/secrets/): +# printf %s "$JWT_SECRET" > ops/secrets/jwt_secret +# printf %s "$STATS_TOKEN" > ops/secrets/stats_token +# printf %s "$METRICS_TOKEN" > ops/secrets/metrics_token # also used by prometheus +secrets: + jwt_secret: + file: ./ops/secrets/jwt_secret + stats_token: + file: ./ops/secrets/stats_token + metrics_token: + file: ./ops/secrets/metrics_token + volumes: pg_data: prom_data: diff --git a/server/config.py b/server/config.py index a6522dab..f3598c63 100644 --- a/server/config.py +++ b/server/config.py @@ -45,6 +45,28 @@ def _list(name: str) -> list[str]: return [item for item in raw.replace(",", " ").split() if item] +def _secret(name: str, default: str | None = None) -> str | None: + """Read a secret from `{name}_FILE` (a path, e.g. a mounted Docker secret) + if set, else from the `{name}` env var, else `default`. + + The file form keeps the value out of the process environment, where it would + otherwise be readable via `docker inspect` and `/proc//environ`. An + unreadable or empty file falls back to the env var so a misconfigured mount + can't silently blank a required secret. + """ + path = os.environ.get(f"{name}_FILE") + if path: + try: + value = open(path, encoding="utf-8").read().strip() + if value: + return value + log.warning("%s_FILE=%s is empty; falling back to %s env var", name, path, name) + except OSError as e: + log.warning("could not read %s_FILE=%s (%s); falling back to %s env var", + name, path, e, name) + return os.environ.get(name, default) + + # ─── Gameplay ──────────────────────────────────────────────────────────── MIN_ROLL_INTERVAL = 0.25 # min seconds between a player's rolls (rate limit) ROLL_ACK_TIMEOUT = 2.0 # wait for the roller's reveal ack before broadcasting @@ -60,7 +82,7 @@ def _list(name: str) -> list[str]: # ─── Shared state (Redis) ──────────────────────────────────────────────── # Game state and cross-instance fan-out live in Redis so the app can run as # multiple instances behind a plain round-robin load balancer. REQUIRED. -REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379/0") +REDIS_URL = _secret("REDIS_URL", "redis://localhost:6379/0") # Identifies this instance in logs / ownership bookkeeping. INSTANCE_ID = os.environ.get("INSTANCE_ID") or socket.gethostname() @@ -110,8 +132,8 @@ def _list(name: str) -> list[str]: # When set, /metrics and /stats/* require `Authorization: Bearer `. # Unset leaves them open — both compose files set tokens so dev and prod are # authenticated; routes.py logs a warning when either is left unset. -METRICS_TOKEN = os.environ.get("METRICS_TOKEN") or None -STATS_TOKEN = os.environ.get("STATS_TOKEN") or None +METRICS_TOKEN = _secret("METRICS_TOKEN") or None +STATS_TOKEN = _secret("STATS_TOKEN") or None # ─── Frontend asset serving ────────────────────────────────────────────── @@ -166,14 +188,14 @@ def _list(name: str) -> list[str]: # in-process; only the Postgres writer + Grafana pusher are skipped. TELEMETRY_ENABLED = _flag("TELEMETRY_ENABLED", True) -POSTGRES_DSN = os.environ.get( +POSTGRES_DSN = _secret( "POSTGRES_DSN", "postgresql://tensies:tensies@postgres:5432/tensies" ) GRAFANA_LIVE_URL = os.environ.get( "GRAFANA_LIVE_URL", "http://grafana:3000/api/live/push" ) GRAFANA_USER = os.environ.get("GRAFANA_USER", "admin") -GRAFANA_PASS = os.environ.get("GRAFANA_PASS", "admin") +GRAFANA_PASS = _secret("GRAFANA_PASS", "admin") PING_INTERVAL = 5.0 # seconds between server→client WS pings @@ -184,7 +206,7 @@ def _list(name: str) -> list[str]: # the target channel id. The bot must be in the server and able to view/send in # that channel (Send Messages + Embed Links). See docs/DISCORD.md. DISCORD_ENABLED = _flag("DISCORD_ENABLED", False) -DISCORD_BOT_TOKEN = os.environ.get("DISCORD_BOT_TOKEN") or None +DISCORD_BOT_TOKEN = _secret("DISCORD_BOT_TOKEN") or None DISCORD_CHANNEL_ID = os.environ.get("DISCORD_CHANNEL_ID") or None DISCORD_API_BASE = os.environ.get( "DISCORD_API_BASE", "https://discord.com/api/v10" @@ -223,5 +245,5 @@ def _list(name: str) -> list[str]: for o in os.environ.get("WEBAUTHN_ORIGIN", APP_URL or "http://localhost:8888").split(",") if o.strip() ] -JWT_SECRET = os.environ.get("JWT_SECRET", "dev-secret-change-in-prod") +JWT_SECRET = _secret("JWT_SECRET", "dev-secret-change-in-prod") JWT_EXPIRY_DAYS = _int("JWT_EXPIRY_DAYS", 30) From 6fbd5e4ea626302cf8f0299aaebb02ba1ba9d087 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:13:57 +0000 Subject: [PATCH 3/9] Harden prod stack: egress split, full read-only, file secrets, hashed deps, image-signing CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the deployment hardening pass. Network egress split: a new two-network topology. `internal` is egress-disabled (internal: true) and holds the data stores — redis, postgres, postgres_exporter, prometheus — so a compromised one has no route to the internet (can't exfiltrate or pull a second stage). `edge` (the only network with a gateway) is joined only by web (drand/Discord egress), nginx (public ingress port), and grafana (admin UI port; its phone-home is disabled via GF_ANALYTICS_*). web sits on both. Full read-only + cap-drop for the last two services: - postgres: read_only root with the data dir on its volume and /tmp + the socket dir on tmpfs; cap_drop ALL + the minimal cap_add the root entrypoint needs to chown the data dir and gosu-drop (CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID). - grafana: read_only root with /tmp tmpfs (writes its sqlite/plugins to the volume); cap_drop ALL (already non-root). Every service is now read_only with all caps dropped (postgres keeps the minimal add-back), no-new-privileges, and cpu/memory/pids caps. All credentials are now mounted files, never env vars (so none appear in `docker inspect` / /proc//environ): - redis reads its password from a secret and writes a tiny config on tmpfs, so the password never lands in argv (vs --requirepass on the command line). - postgres uses POSTGRES_PASSWORD_FILE; postgres_exporter uses the split DATA_SOURCE_URI/USER + DATA_SOURCE_PASS_FILE form. - grafana uses GF_SECURITY_ADMIN_PASSWORD__FILE and reads the datasource password via the $__file{} provisioning provider. - web reads the full redis URL / postgres DSN / grafana pass from files (REDIS_URL_FILE etc.) via config._secret(), alongside jwt/stats/metrics. Hashed, reproducible dependency install: requirements.lock is regenerated from requirements.txt with pip-compile --generate-hashes (it was stale and unhashed — e.g. asyncpg 0.30 vs the declared 0.31). The Dockerfile installs it with --require-hashes so a tampered or MITM'd index can't substitute an artifact. Supply-chain CI (.github/workflows/image-security.yml): every PR builds the web image, fails on fixable HIGH/CRITICAL CVEs (Trivy), and uploads an SPDX SBOM (Syft). On main, both images are pushed to GHCR with SLSA provenance + SBOM attestations and keyless-signed with cosign (Sigstore OIDC, no long-lived keys). Verified end-to-end: brought up the full prod compose with generated secret files — postgres + redis reach Healthy (read-only postgres, file passwords work), migrations apply, web serves 200 through nginx, grafana serves 200 (datasource provisioned via $__file{}). Confirmed: the four data stores are on `internal` only with NO default route (egress blocked) while edge has one; web's env exposes only *_FILE pointers (no credential values); the redis password is absent from its process argv; all seven containers are ReadonlyRootfs with 0 restarts. The web image builds with --require-hashes (all 37 packages hash-verified); prod compose renders valid; ruff passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- .env.prod.example | 26 +- .github/workflows/image-security.yml | 112 +++ Dockerfile | 10 +- docker-compose.prod.yml | 147 ++- .../datasources/datasources.yaml | 8 +- requirements.lock | 909 +++++++++++++++++- 6 files changed, 1122 insertions(+), 90 deletions(-) create mode 100644 .github/workflows/image-security.yml diff --git a/.env.prod.example b/.env.prod.example index 1ba1b49a..3d99a233 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -1,19 +1,21 @@ -# Copy to .env.prod and fill in real secrets. The app-owned secrets below are -# delivered to the web container as MOUNTED FILES (not env vars, so they stay out -# of `docker inspect` / /proc//environ). Materialise the files first, then -# bring the stack up: +# Copy to .env.prod and fill in real secrets. EVERY secret is delivered to its +# container as a MOUNTED FILE (not an env var, so nothing shows up in +# `docker inspect` / /proc//environ). The values below seed those files. +# Materialise them all from .env.prod, then bring the stack up: +# # set -a; . ./.env.prod; set +a # mkdir -p ops/secrets -# printf %s "$JWT_SECRET" > ops/secrets/jwt_secret -# printf %s "$STATS_TOKEN" > ops/secrets/stats_token -# printf %s "$METRICS_TOKEN" > ops/secrets/metrics_token +# printf %s "$JWT_SECRET" > ops/secrets/jwt_secret +# printf %s "$STATS_TOKEN" > ops/secrets/stats_token +# printf %s "$METRICS_TOKEN" > ops/secrets/metrics_token +# printf %s "$POSTGRES_PASSWORD" > ops/secrets/pg_password +# printf %s "$REDIS_PASSWORD" > ops/secrets/redis_password +# printf %s "$GRAFANA_ADMIN_PASSWORD" > ops/secrets/grafana_admin_password +# printf 'redis://:%s@redis:6379/0' "$REDIS_PASSWORD" > ops/secrets/redis_url +# printf 'postgresql://tensies:%s@postgres:5432/tensies' "$POSTGRES_PASSWORD" > ops/secrets/pg_dsn # docker compose -f docker-compose.prod.yml --env-file .env.prod up -d --build -# .env.prod and ops/secrets/ are gitignored — never commit real secrets. # -# (REDIS_PASSWORD / POSTGRES_PASSWORD / GRAFANA_ADMIN_PASSWORD are still passed via -# env because the redis/postgres/grafana images and connection URLs consume them -# directly; the app's config._secret() also accepts *_FILE for REDIS_URL, -# POSTGRES_DSN and GRAFANA_PASS if you wire those to files later.) +# .env.prod and ops/secrets/ are gitignored — never commit real secrets. # Strong, unique values (e.g. `openssl rand -hex 32`): REDIS_PASSWORD=change-me diff --git a/.github/workflows/image-security.yml b/.github/workflows/image-security.yml new file mode 100644 index 00000000..90f4265b --- /dev/null +++ b/.github/workflows/image-security.yml @@ -0,0 +1,112 @@ +# Image supply-chain gate (phase-3 security layer). +# scan — build the web image, fail the PR on fixable HIGH/CRITICAL CVEs +# (Trivy), and publish an SBOM artifact (Syft / SPDX). Runs on every +# PR and on main. +# publish — main only: build + push the web and nginx images to GHCR with SLSA +# provenance + SBOM attestations, then keyless-sign them with cosign +# (Sigstore OIDC — no long-lived keys). Signing needs a pushed digest, +# which is why publishing lives here rather than in the build-only +# `docker` job in ci.yml. +# +# Action versions are major-tag pinned to match ci.yml; SHA-pinning is the same +# deferred follow-up noted there. +name: image-security + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: imgsec-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + scan: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4 + - name: Build app image (web stage), load into the local daemon + uses: docker/build-push-action@v7 + with: + context: . + target: web + load: true + tags: tensies:scan + cache-from: type=gha + cache-to: type=gha,mode=max + - name: Trivy scan — fail on fixable HIGH/CRITICAL (OS + Python deps) + uses: aquasecurity/trivy-action@0.29.0 + with: + image-ref: tensies:scan + format: table + exit-code: "1" + severity: HIGH,CRITICAL + ignore-unfixed: true + vuln-type: os,library + - name: Generate SBOM (SPDX JSON) and upload as a build artifact + uses: anchore/sbom-action@v0 + with: + image: tensies:scan + format: spdx-json + output-file: tensies-web.sbom.spdx.json + upload-artifact: true + + publish: + # Push + sign only the immutable main builds; PRs just scan above. + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: scan + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + contents: read + packages: write # push images to GHCR + id-token: write # cosign keyless signing (Sigstore OIDC) + env: + IMAGE: ghcr.io/${{ github.repository }} + steps: + - uses: actions/checkout@v7 + - uses: docker/setup-buildx-action@v4 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - uses: sigstore/cosign-installer@v3 + - name: Build & push web (with provenance + SBOM attestations) + id: web + uses: docker/build-push-action@v7 + with: + context: . + target: web + push: true + tags: | + ${{ env.IMAGE }}/web:latest + ${{ env.IMAGE }}/web:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + - name: Build & push nginx (with provenance + SBOM attestations) + id: nginx + uses: docker/build-push-action@v7 + with: + context: . + target: nginx + push: true + tags: | + ${{ env.IMAGE }}/nginx:latest + ${{ env.IMAGE }}/nginx:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + provenance: true + sbom: true + - name: Cosign keyless-sign both images by digest + run: | + cosign sign --yes "${IMAGE}/web@${{ steps.web.outputs.digest }}" + cosign sign --yes "${IMAGE}/nginx@${{ steps.nginx.outputs.digest }}" diff --git a/Dockerfile b/Dockerfile index 605c2dc0..28cd2989 100644 --- a/Dockerfile +++ b/Dockerfile @@ -55,8 +55,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" -# Install deps first for layer caching. Prefer the fully-pinned lock for -# reproducible/prod builds; fall back to requirements.txt if the lock is absent. +# Install deps first for layer caching. Prefer the fully-pinned, hashed lock for +# reproducible/prod builds (installed with --require-hashes so a tampered or +# MITM'd index can't substitute a different artifact); fall back to the unhashed +# requirements.txt only if the lock is absent. COPY requirements.txt requirements.lock* ./ # Optional build-time CA bundle (BuildKit secret `proxy_ca`): lets pip reach the # index behind a TLS-intercepting egress proxy. Absent in normal builds -> plain @@ -64,7 +66,9 @@ COPY requirements.txt requirements.lock* ./ RUN --mount=type=secret,id=proxy_ca \ pip install --no-cache-dir \ $(test -s /run/secrets/proxy_ca && echo --cert=/run/secrets/proxy_ca) \ - -r $( [ -f requirements.lock ] && echo requirements.lock || echo requirements.txt ) + $( [ -f requirements.lock ] \ + && echo "--require-hashes -r requirements.lock" \ + || echo "-r requirements.txt" ) # ── Stage 3b: the Python app (default build target) ─────────────────────────── # Clean slim base with NO build toolchain — only the prebuilt venv and the app diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d7d77b40..35bca2d1 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -35,14 +35,16 @@ services: expose: - "8000" environment: - REDIS_URL: "redis://:${REDIS_PASSWORD:?set REDIS_PASSWORD}@redis:6379/0" - POSTGRES_DSN: "postgresql://tensies:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/tensies" + # Every credential is delivered as a mounted file (see the `secrets:` block + # below + the top-level definitions), NOT an env var — so none of them show + # up in `docker inspect` or /proc//environ. config._secret() reads the + # matching *_FILE path. The redis URL / postgres DSN carry their passwords + # inline, so the whole connection string lives in its secret file. + REDIS_URL_FILE: /run/secrets/redis_url + POSTGRES_DSN_FILE: /run/secrets/pg_dsn GRAFANA_LIVE_URL: http://grafana:3000/api/live/push GRAFANA_USER: admin - GRAFANA_PASS: "${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}" - # App-owned secrets are delivered as mounted files (see the `secrets:` block - # below + top-level definitions), NOT env vars — so they don't show up in - # `docker inspect` or /proc//environ. config._secret() reads *_FILE. + GRAFANA_PASS_FILE: /run/secrets/grafana_admin_password METRICS_TOKEN_FILE: /run/secrets/metrics_token STATS_TOKEN_FILE: /run/secrets/stats_token ALLOWED_ORIGINS: "${ALLOWED_ORIGINS:?set ALLOWED_ORIGINS}" @@ -96,12 +98,18 @@ services: condition: service_healthy redis: condition: service_healthy - networks: [internal] + # `edge` carries the only outbound internet the stack needs (the drand beacon + # + Discord); `internal` (egress-disabled) reaches redis/postgres/grafana. The + # backends sit on `internal` only, so a compromised one can't phone home. + networks: [internal, edge] restart: unless-stopped secrets: - jwt_secret - stats_token - metrics_token + - redis_url + - pg_dsn + - grafana_admin_password # ── Hardening ────────────────────────────────────────────────────────── # The app writes nothing to disk at runtime and PYTHONDONTWRITEBYTECODE is # set, so the root FS is read-only; only /tmp is a (small) tmpfs, mounted @@ -145,7 +153,10 @@ services: ports: - "${WEB_PUBLISH:-127.0.0.1:8000}:8080" depends_on: [web] - networks: [internal] + # `edge` only: nginx needs a non-internal network for its published port to + # work (Docker won't NAT a host port to an internal-only container) and to + # reach `web` (also on edge). It never initiates outbound itself. + networks: [edge] restart: unless-stopped # Read-only root FS: nginx's pid, temp dirs, and logs are routed to /tmp + # stdout/stderr in ops/nginx.conf, so /tmp (tmpfs) is the only writable path. @@ -164,18 +175,31 @@ services: # Run directly as the redis user so the entrypoint never needs to drop privs # (su-exec would require CAP_SETUID/SETGID, which we drop below). user: redis - command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?set REDIS_PASSWORD}", - "--save", "", "--appendonly", "no"] - # Persistence is off (no RDB/AOF), so the root FS can be read-only with no - # writable volume; no caps or privilege escalation are needed. + # The password comes from a mounted secret file. We materialise a tiny config + # on the /tmp tmpfs and exec redis-server against it, rather than passing + # --requirepass on the command line — so the password never lands in the + # process argv (visible via `ps` / /proc//cmdline) or the environment. + # `cat` runs as a child, so the secret isn't in the `sh -c` argv either. + entrypoint: ["sh", "-c"] + command: + - | + printf 'requirepass %s\nsave ""\nappendonly no\n' "$$(cat /run/secrets/redis_password)" > /tmp/redis.conf + exec redis-server /tmp/redis.conf + secrets: + - redis_password + # Persistence is off (no RDB/AOF), so the root FS can be read-only; only the + # /tmp tmpfs (for the generated config) is writable. No caps / no escalation. read_only: true + tmpfs: + - /tmp:size=8m,mode=1777,noexec,nosuid,nodev cap_drop: [ALL] security_opt: ["no-new-privileges:true"] pids_limit: 128 cpus: 0.5 mem_limit: 256m healthcheck: - test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + # REDISCLI_AUTH keeps the password out of argv (vs redis-cli -a ). + test: ["CMD-SHELL", "REDISCLI_AUTH=\"$(cat /run/secrets/redis_password)\" redis-cli ping | grep -q PONG"] interval: 5s timeout: 3s retries: 10 @@ -186,15 +210,23 @@ services: image: postgres:16.4-alpine@sha256:5660c2cbfea50c7a9127d17dc4e48543eedd3d7a41a595a2dfa572471e37e64c environment: POSTGRES_USER: tensies - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" + # Read from a mounted secret file instead of the environment. + POSTGRES_PASSWORD_FILE: /run/secrets/pg_password POSTGRES_DB: tensies + secrets: + - pg_password volumes: - pg_data:/var/lib/postgresql/data - # no-new-privileges is always safe. We stop short of read_only/cap_drop here: - # the postgres entrypoint runs as root and uses gosu to drop to the postgres - # user (needs CHOWN/SET[UG]ID/DAC_OVERRIDE/FOWNER) and writes to its data dir - # plus /var/run/postgresql + /tmp — a read-only root FS needs those tmpfs'd - # and the matching cap_add set, which is a separate, test-heavy change. + # Read-only root FS: the data dir lives on its volume and the runtime scratch + # paths (/tmp + the postgres socket dir) are tmpfs. The entrypoint starts as + # root to chown the data dir and gosu-drops to the postgres user, so it keeps + # exactly the caps that needs and nothing else. + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777,nosuid,nodev + - /run/postgresql:size=64m,mode=0775,uid=70,gid=70,nosuid,nodev + cap_drop: [ALL] + cap_add: [CHOWN, DAC_OVERRIDE, FOWNER, SETGID, SETUID] security_opt: ["no-new-privileges:true"] pids_limit: 256 cpus: 1.0 @@ -231,7 +263,13 @@ services: postgres_exporter: image: prometheuscommunity/postgres-exporter:v0.15.0@sha256:386b12d19eab2a37d7cd8ca8b4c7491cc7a830d9581f49af6c98a393da9605e6 environment: - DATA_SOURCE_NAME: "postgresql://tensies:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}@postgres:5432/tensies?sslmode=disable" + # Split form so the password comes from a mounted secret file, not the DSN + # in the environment. + DATA_SOURCE_URI: "postgres:5432/tensies?sslmode=disable" + DATA_SOURCE_USER: tensies + DATA_SOURCE_PASS_FILE: /run/secrets/pg_password + secrets: + - pg_password # Stateless Go binary that writes nothing — fully locked down. read_only: true cap_drop: [ALL] @@ -249,27 +287,39 @@ services: image: grafana/grafana:11.2.0@sha256:408afb9726de5122b00a2576763a8a57a3c86d5b0eff5305bc994ceb3eb96c3f environment: GF_AUTH_ANONYMOUS_ENABLED: "false" - GF_SECURITY_ADMIN_PASSWORD: "${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}" - # Consumed by the provisioned Postgres datasource (it reads ${POSTGRES_PASSWORD} - # from Grafana's env — see ops/grafana/provisioning-prod/datasources). - POSTGRES_PASSWORD: "${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD}" + # Admin password from a mounted secret file (Grafana's __FILE convention). + GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password GF_LIVE_ALLOWED_ORIGINS: "${GRAFANA_ROOT_URL:?set GRAFANA_ROOT_URL}" GF_USERS_DEFAULT_THEME: dark GF_DASHBOARDS_DEFAULT_HOME_DASHBOARD_PATH: /var/lib/grafana/dashboards/live-games.json GF_SERVER_ROOT_URL: "${GRAFANA_ROOT_URL:?set GRAFANA_ROOT_URL}" + # No outbound internet on the `internal` network — turn off the update check + # and usage analytics so Grafana doesn't try (and log failures) on boot. + GF_ANALYTICS_REPORTING_ENABLED: "false" + GF_ANALYTICS_CHECK_FOR_UPDATES: "false" + GF_ANALYTICS_CHECK_FOR_PLUGIN_UPDATES: "false" # Retained to match dev dashboard rendering (owner decision: don't change # the Grafana flag). The stored-XSS vector via player names is closed at # the source instead — names are sanitized server-side at intake (M1). GF_PANELS_DISABLE_SANITIZE_HTML: "true" + # The Postgres datasource password is read from the mounted secret file via + # Grafana's $__file{} provider (see provisioning-prod/datasources.yaml), so no + # DB password is in Grafana's environment. + secrets: + - grafana_admin_password + - pg_password volumes: - ./ops/grafana/provisioning-prod/datasources:/etc/grafana/provisioning/datasources:ro - ./ops/grafana/provisioning/dashboards:/etc/grafana/provisioning/dashboards:ro - ./ops/grafana/dashboards:/var/lib/grafana/dashboards:ro - grafana_data:/var/lib/grafana - # no-new-privileges is safe; grafana writes its sqlite + plugins under - # /var/lib/grafana (the volume) and reads provisioning read-only, so a full - # read_only root FS would need /tmp + a few dirs tmpfs'd — left as a - # follow-up to avoid breaking plugin/datasource provisioning. + # Read-only root FS: Grafana writes its sqlite + plugins under /var/lib/grafana + # (the volume) and provisioning is mounted read-only; /tmp is the only other + # writable path it needs. Runs as the non-root grafana user already. + read_only: true + tmpfs: + - /tmp:size=64m,mode=1777,nosuid,nodev + cap_drop: [ALL] security_opt: ["no-new-privileges:true"] pids_limit: 256 cpus: 1.0 @@ -280,23 +330,38 @@ services: # admin UI shouldn't face the public internet; tunnel or firewall it. ports: - "${GRAFANA_PUBLISH:-127.0.0.1:8889}:3000" - networks: [internal] + # `edge` is required for the host-published admin port (Docker won't NAT a + # host port to an internal-only container); `internal` reaches postgres + + # prometheus. Being on edge means Grafana can phone home (plugin/update + # checks) — those are disabled above; the true data stores stay egress-free. + networks: [internal, edge] depends_on: - prometheus - postgres restart: unless-stopped networks: + # Egress-disabled: services on `internal` have NO route to the internet. The + # data stores — redis, postgres, postgres_exporter, prometheus — sit on this + # network ONLY, so a compromised one can't exfiltrate or pull a second-stage + # payload. (Docker also won't NAT a host port to an internal-only container, so + # anything that needs a published port must also join `edge` below.) internal: driver: bridge + internal: true + # Has a gateway (egress + host-published ports). Joined only by the services + # that genuinely need it: web (drand beacon + Discord egress), nginx (the + # public ingress port), and grafana (its admin UI port; its outbound phone-home + # is disabled via GF_ANALYTICS_* above). + edge: + driver: bridge -# App-owned secrets, mounted into the web container as files under /run/secrets -# (config._secret reads the matching *_FILE env). Delivering them as files keeps -# them out of the container environment (docker inspect / /proc//environ). -# Create the source files before `up` (they're gitignored under ops/secrets/): -# printf %s "$JWT_SECRET" > ops/secrets/jwt_secret -# printf %s "$STATS_TOKEN" > ops/secrets/stats_token -# printf %s "$METRICS_TOKEN" > ops/secrets/metrics_token # also used by prometheus +# All secrets are mounted into the consuming containers as files under +# /run/secrets (kept out of the environment, so they don't show up in +# `docker inspect` or /proc//environ). Materialise the source files before +# `up` — they're gitignored under ops/secrets/. See .env.prod.example for a +# one-liner that derives them all from .env.prod, e.g.: +# printf 'redis://:%s@redis:6379/0' "$REDIS_PASSWORD" > ops/secrets/redis_url secrets: jwt_secret: file: ./ops/secrets/jwt_secret @@ -304,6 +369,16 @@ secrets: file: ./ops/secrets/stats_token metrics_token: file: ./ops/secrets/metrics_token + redis_password: + file: ./ops/secrets/redis_password + redis_url: + file: ./ops/secrets/redis_url + pg_password: + file: ./ops/secrets/pg_password + pg_dsn: + file: ./ops/secrets/pg_dsn + grafana_admin_password: + file: ./ops/secrets/grafana_admin_password volumes: pg_data: diff --git a/ops/grafana/provisioning-prod/datasources/datasources.yaml b/ops/grafana/provisioning-prod/datasources/datasources.yaml index bf5acd07..05cade1a 100644 --- a/ops/grafana/provisioning-prod/datasources/datasources.yaml +++ b/ops/grafana/provisioning-prod/datasources/datasources.yaml @@ -1,8 +1,8 @@ apiVersion: 1 -# Prod datasource provisioning. The Postgres password is read from the -# environment (Grafana expands ${VAR} in provisioning files) rather than being -# committed, unlike the dev provisioning/datasources/datasources.yaml. +# Prod datasource provisioning. The Postgres password is read from the mounted +# secret file via Grafana's $__file{} provider — so it's never in Grafana's +# environment (nor committed, unlike the dev provisioning/datasources). datasources: - name: Postgres uid: postgres @@ -11,7 +11,7 @@ datasources: url: postgres:5432 user: tensies secureJsonData: - password: ${POSTGRES_PASSWORD} + password: $__file{/run/secrets/pg_password} jsonData: database: tensies sslmode: disable diff --git a/requirements.lock b/requirements.lock index d6d70245..695fdad0 100644 --- a/requirements.lock +++ b/requirements.lock @@ -1,35 +1,874 @@ -# Fully-pinned dependency closure for reproducible builds. -# Regenerate with: pip install -r requirements.txt && pip freeze -# For maximum supply-chain safety, generate a hashed lock with pip-tools/uv -# and install with `pip install --require-hashes -r `. -annotated-types==0.7.0 -anyio==4.13.0 -asyncpg==0.30.0 -certifi==2026.2.25 -click==8.4.1 -fastapi==0.136.3 -h11==0.16.0 -httpcore==1.0.9 -httptools==0.8.0 -httpx==0.27.2 -idna==3.11 -prometheus-client==0.21.0 -pydantic==2.13.4 -python-dotenv==1.2.2 -redis==8.0.0 -sniffio==1.3.1 -starlette==1.2.1 -uvicorn==0.32.0 -uvloop==0.22.1 -watchfiles==1.2.0 -webauthn==2.8.0 -websockets==16.0 -PyJWT==2.13.0 -cbor2==5.9.0 -cffi==2.0.0 -cryptography==49.0.0 -pyasn1==0.6.3 -pyasn1-modules==0.4.2 -pycparser==3.0 -pyOpenSSL==26.3.0 -blspy==2.0.3 +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --allow-unsafe --cert=None --client-cert=None --generate-hashes --index-url=None --output-file=/tmp/requirements.lock.new --pip-args=None requirements.txt +# +annotated-doc==0.0.4 \ + --hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \ + --hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4 + # via fastapi +annotated-types==0.7.0 \ + --hash=sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53 \ + --hash=sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89 + # via pydantic +anyio==4.14.0 \ + --hash=sha256:b47c1f9ccf73e67021df785332508f99379c68fa7d0684e8e3492cb1d4b23f89 \ + --hash=sha256:dd9b7a2a9799ed6552fde617b2c5df02b7fdd7d88392fc48101e51bae46164d9 + # via + # httpx + # starlette + # watchfiles +asyncpg==0.31.0 \ + --hash=sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8 \ + --hash=sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be \ + --hash=sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be \ + --hash=sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2 \ + --hash=sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d \ + --hash=sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a \ + --hash=sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7 \ + --hash=sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218 \ + --hash=sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d \ + --hash=sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602 \ + --hash=sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6 \ + --hash=sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab \ + --hash=sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095 \ + --hash=sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5 \ + --hash=sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9 \ + --hash=sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9 \ + --hash=sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c \ + --hash=sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec \ + --hash=sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8 \ + --hash=sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047 \ + --hash=sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e \ + --hash=sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24 \ + --hash=sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31 \ + --hash=sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186 \ + --hash=sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3 \ + --hash=sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61 \ + --hash=sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a \ + --hash=sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1 \ + --hash=sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2 \ + --hash=sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2 \ + --hash=sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540 \ + --hash=sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c \ + --hash=sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8 \ + --hash=sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671 \ + --hash=sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad \ + --hash=sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d \ + --hash=sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298 \ + --hash=sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008 \ + --hash=sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3 \ + --hash=sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20 \ + --hash=sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2 \ + --hash=sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4 \ + --hash=sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109 \ + --hash=sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403 \ + --hash=sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6 \ + --hash=sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a \ + --hash=sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b \ + --hash=sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735 \ + --hash=sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b \ + --hash=sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab \ + --hash=sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e \ + --hash=sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da \ + --hash=sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366 \ + --hash=sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95 \ + --hash=sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d \ + --hash=sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44 \ + --hash=sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696 + # via -r requirements.txt +blspy==2.0.3 \ + --hash=sha256:0ac98528abfd04bd11c1cad59127b3fa6e03f4d537497d6e789841c706aad5e4 \ + --hash=sha256:0bd79a85c142afddee7a5eed58b041a28fa7c7f3c07419aa8bb03a9213a8c6e7 \ + --hash=sha256:139772d265c3384ee3972c2d81ff0605ebabc8221fef3b699981246246ca8c41 \ + --hash=sha256:246dd0c69d4e53aa66c06d674402040f2f86b352afe2135712f7ec6a62b2ad8c \ + --hash=sha256:43e7da8f2880106d522dfb26f8015a6771ec3306c3baa7fc22561f6c0ba38a7c \ + --hash=sha256:62491c8a4b8810771443517fd2e2738a4c96d8a1fc5a1b3ae522d7d6bace3f49 \ + --hash=sha256:647abe2f898eb99e7e9978eec2a4e42589a4378a73f1f8037f8594e4b92b3797 \ + --hash=sha256:727ee8ccd6a9871b116e8e8b787a3fa4cf864bcab3b8b9272246f9057bd4510d \ + --hash=sha256:8089718d17bd722826a9518548e904f4dfe8da14fa9ffee4fd156fa71258f793 \ + --hash=sha256:869efdb5196c8d14d07c8e92cf758e298b0829b93a565ba35dd642c333757d1a \ + --hash=sha256:903afd454563ce26c6b55c9b11500b688503103f96673e37b8424a1af0a47e96 \ + --hash=sha256:9048930d4c7984a6aa9b4588c45f1a75532be5f0426453e895bf6b57ad792eeb \ + --hash=sha256:9103e08c1ed3efa98c220cd979580f681365e26167ebd717f4ecbda27d24b6a7 \ + --hash=sha256:96c78e035ea15c826e2330ad2a2619a6cb3519ac977f74a608911ef2c105d3ec \ + --hash=sha256:a1dc830abd7fd663a39315bdaef155a32a97936b6fffe84c313db9098bfdebfd \ + --hash=sha256:a252263e4ccdebb81b03bc570a08bebe703d335c912e3488921569f9b2b2400a \ + --hash=sha256:a5805418da017833f9df22bb382afac30f46747e25562563c65dff1bd502d27b \ + --hash=sha256:b26199bdbbbd02aacd9ffc802aad983a76b1ffd6745a6b838629b155ec0c63fe \ + --hash=sha256:b3af4751cc5751778294deb2f561b52f4130fcfb142a3803be9151910ba38ff8 \ + --hash=sha256:c739f9d9bf322c80c3bb41c8db7b59a7771dda4583fca70e25c582d1cb00234b \ + --hash=sha256:d18c877523a6ab1c08f06f1962e79c49b52e8a77a8a67dab7759fd2ed0908125 \ + --hash=sha256:df9bad403835dd6aec941089089bbf43a3a4fde038acbe0062d3efa6a7ecae83 \ + --hash=sha256:e8b0c4fbab2461772c05cc02cd29a356b6115368afa7202cacd83bbf3b047534 \ + --hash=sha256:f0e168006f877319d29686be04c9226e03df055bf2c9583ac696639bb732a63c \ + --hash=sha256:fe2ca8e6c4e4014b5ebbf8d67e07a93161a72fc86f9d7c193d95da6886743be6 \ + --hash=sha256:ff263b4a8967a0337d31904837ca53678dd3982187c9d37b34804e8b682ffce9 + # via -r requirements.txt +cbor2==5.9.0 \ + --hash=sha256:0322296b9d52f55880e300ba8ba09ecf644303b99b51138bbb1c0fb644fa7c3e \ + --hash=sha256:0485d3372fc832c5e16d4eb45fa1a20fc53e806e6c29a1d2b0d3e176cedd52b9 \ + --hash=sha256:08388ea54195738602b4c4999966bcaef6f0b17d293c9658658409d9fff96f57 \ + --hash=sha256:1d02b65f070fd726bdc310d927228975bb655d155bf059b6eb7cacefb3dca86f \ + --hash=sha256:1da96ce5d852fe3d342c1eb2c202a52d1c97edfddc9230f1be7e02674662bf26 \ + --hash=sha256:1f223dffb1bcdd2764665f04c1152943d9daa4bc124a576cd8dee1cad4264313 \ + --hash=sha256:23606d31ba1368bd1b6602e3020ee88fe9523ca80e8630faf6b2fc904fd84560 \ + --hash=sha256:2372d357d403e7912f104ff085950ffc82a5854d6d717f1ca1ce16a40a0ef5a7 \ + --hash=sha256:25bec7beb2089465382b1be72e78667fe9090598800826559c3e3008cf0db743 \ + --hash=sha256:27695cbd70c90b8de5c4a284642c2836449b14e2c2e07e3ffe0744cb7669a01b \ + --hash=sha256:2a54fbb32cb828c214f7f333a707e4aec61182e7efdc06ea5d9596d3ecee624a \ + --hash=sha256:3095dc49e75572841a9534cbfdabc2a17487ea4ee33341436abc4a7ac7245a3a \ + --hash=sha256:34a6cb15e6ab6a8eae94ad2041731cd3ef786af43a8df99f847969af5b902ee7 \ + --hash=sha256:380e534482b843e43442b87d8777a7bf9bed20cb7526f89b780c3400f617304b \ + --hash=sha256:420d2490c7836c81151b4bd591c35cffc55391e33e7e333c50fda391bcea7d31 \ + --hash=sha256:422817286c1d0ce947fb2f7eca9212b39bddd7231e8b452e2d2cc52f15332dba \ + --hash=sha256:4753a6d1bc71054d9179557bc65740860f185095ccb401d46637fff028a5b3ec \ + --hash=sha256:4aa07b392cc3d76fb31c08a46a226b58c320d1c172ff3073e864409ced7bc50f \ + --hash=sha256:4cd43d8fc374b31643b2830910f28177a606a7bc84975a62675dd3f2e320fc7b \ + --hash=sha256:5326336f633cc89dfe543c78829c16c3a6449c2c03277d1ddba99086c3323363 \ + --hash=sha256:53cfa49e0df9c639beb871d480de098eedc81eb63ff29f2dc922720d7577b676 \ + --hash=sha256:55bea0dd9a7d354e35f4e5fe58ceab393e76962713749dc3a0a64a0e5d19545e \ + --hash=sha256:5e702b02d42a5ace45425b595ffe70fe35aebaf9a3cdfdc2c758b6189c744422 \ + --hash=sha256:65f8eac3268c608533f326f0fd9010ab1b2a8a917b05edaf3853116336821669 \ + --hash=sha256:7221483fad0c63afa4244624d552abf89d7dfdbc5f5edfc56fc1ff2b4b818975 \ + --hash=sha256:7d1ddc4541e7367ac58c2470cc0df847f7137167fe4f5729e2d3cc0b993d7da4 \ + --hash=sha256:837754ece9052b3f607047e1741e5f852a538aa2b0ee3db11c82a8fa11804aa4 \ + --hash=sha256:85c7a46279ac8f226e1059275221e6b3d0e370d2bb6bd0500f9780781615bcea \ + --hash=sha256:86baf870d4c0bfc6f79de3801f3860a84ab76d9c8b0abb7f081f2c14c38d79d3 \ + --hash=sha256:971d425b3a23b75953d8853d5f9911bdeefa09d759ee3b5e6b07b5ff3cbd9073 \ + --hash=sha256:9a4907e0c3035bb8836116854ed8e56d8aef23909d601fa59706320897ec2551 \ + --hash=sha256:a9d6e4e0f988b0e766509a8071975a8ee99f930e14a524620bf38083106158d2 \ + --hash=sha256:ac684fe195c39821fca70d18afbf748f728aefbfbf88456018d299e559b8cae0 \ + --hash=sha256:ae6c706ac1d85a0b3cb3395308fd0c4d55e3202b4760773675957e93cdff45fc \ + --hash=sha256:cc5efec69055c3c470997935d95762be7e4bfd1248d88fb1a33bb7e0f45712e9 \ + --hash=sha256:d1a21c006760f95acd9509cc5a7d15d6fc82e58f721f94fa9039b4e77189a6e5 \ + --hash=sha256:d8524a8c142c3cc228e635f8a97499a6c0b18ca91382e8276565658035cdcb6d \ + --hash=sha256:dcf0f695873e5c94bd072d6af8698e72b8fb7f7a18f37e0bced1041b7111a6cf \ + --hash=sha256:f29e5c3abcc91c1aeefecde0e057bf33f1655588d3065c6560c30ceb3be6f333 \ + --hash=sha256:f797532d13469f2193e5c16e827d8df7a8c33674b19be755790b54ab231e6a73 \ + --hash=sha256:f7c9751a9611601ab326d8f5837f01379195bbf06175fb4effeb552140e7c9e8 \ + --hash=sha256:fb7afe77f8d269e42d7c4b515c6fd14f1ccc0625379fb6829b269f493d16eddd \ + --hash=sha256:fbb06f34aa645b4deca66643bba3d400d20c15312d1fe88d429be60c1ab50f27 \ + --hash=sha256:fbdcf4d74acbeb7672e6413e81cd2c1ced1a4a8cf949484ac54e9af5265c3c72 + # via webauthn +certifi==2026.6.17 \ + --hash=sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432 \ + --hash=sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db + # via + # httpcore + # httpx +cffi==2.0.0 \ + --hash=sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb \ + --hash=sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b \ + --hash=sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f \ + --hash=sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9 \ + --hash=sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44 \ + --hash=sha256:0f6084a0ea23d05d20c3edcda20c3d006f9b6f3fefeac38f59262e10cef47ee2 \ + --hash=sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c \ + --hash=sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75 \ + --hash=sha256:1cd13c99ce269b3ed80b417dcd591415d3372bcac067009b6e0f59c7d4015e65 \ + --hash=sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e \ + --hash=sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a \ + --hash=sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e \ + --hash=sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25 \ + --hash=sha256:2081580ebb843f759b9f617314a24ed5738c51d2aee65d31e02f6f7a2b97707a \ + --hash=sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe \ + --hash=sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b \ + --hash=sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91 \ + --hash=sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592 \ + --hash=sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187 \ + --hash=sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c \ + --hash=sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1 \ + --hash=sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94 \ + --hash=sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba \ + --hash=sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb \ + --hash=sha256:3f4d46d8b35698056ec29bca21546e1551a205058ae1a181d871e278b0b28165 \ + --hash=sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529 \ + --hash=sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca \ + --hash=sha256:4647afc2f90d1ddd33441e5b0e85b16b12ddec4fca55f0d9671fef036ecca27c \ + --hash=sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6 \ + --hash=sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c \ + --hash=sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0 \ + --hash=sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743 \ + --hash=sha256:61d028e90346df14fedc3d1e5441df818d095f3b87d286825dfcbd6459b7ef63 \ + --hash=sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5 \ + --hash=sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5 \ + --hash=sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4 \ + --hash=sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d \ + --hash=sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b \ + --hash=sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93 \ + --hash=sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205 \ + --hash=sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27 \ + --hash=sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512 \ + --hash=sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d \ + --hash=sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c \ + --hash=sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037 \ + --hash=sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26 \ + --hash=sha256:89472c9762729b5ae1ad974b777416bfda4ac5642423fa93bd57a09204712322 \ + --hash=sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb \ + --hash=sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c \ + --hash=sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8 \ + --hash=sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4 \ + --hash=sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414 \ + --hash=sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9 \ + --hash=sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664 \ + --hash=sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9 \ + --hash=sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775 \ + --hash=sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739 \ + --hash=sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc \ + --hash=sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062 \ + --hash=sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe \ + --hash=sha256:b882b3df248017dba09d6b16defe9b5c407fe32fc7c65a9c69798e6175601be9 \ + --hash=sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92 \ + --hash=sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5 \ + --hash=sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13 \ + --hash=sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d \ + --hash=sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26 \ + --hash=sha256:cb527a79772e5ef98fb1d700678fe031e353e765d1ca2d409c92263c6d43e09f \ + --hash=sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495 \ + --hash=sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b \ + --hash=sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6 \ + --hash=sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c \ + --hash=sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef \ + --hash=sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5 \ + --hash=sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18 \ + --hash=sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad \ + --hash=sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3 \ + --hash=sha256:de8dad4425a6ca6e4e5e297b27b5c824ecc7581910bf9aee86cb6835e6812aa7 \ + --hash=sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5 \ + --hash=sha256:e6e73b9e02893c764e7e8d5bb5ce277f1a009cd5243f8228f75f842bf937c534 \ + --hash=sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49 \ + --hash=sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2 \ + --hash=sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5 \ + --hash=sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453 \ + --hash=sha256:fe562eb1a64e67dd297ccc4f5addea2501664954f2692b69a76449ec7913ecbf + # via cryptography +click==8.4.1 \ + --hash=sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2 \ + --hash=sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96 + # via uvicorn +cryptography==49.0.0 \ + --hash=sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001 \ + --hash=sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122 \ + --hash=sha256:084ef1af862eb07ec46d25f68689f2102a9fc0e05ce7b80f14f5fe51e4eef0f6 \ + --hash=sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c \ + --hash=sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325 \ + --hash=sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69 \ + --hash=sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d \ + --hash=sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36 \ + --hash=sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc \ + --hash=sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6 \ + --hash=sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b \ + --hash=sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27 \ + --hash=sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61 \ + --hash=sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18 \ + --hash=sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db \ + --hash=sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b \ + --hash=sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb \ + --hash=sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2 \ + --hash=sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459 \ + --hash=sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e \ + --hash=sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21 \ + --hash=sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8 \ + --hash=sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7 \ + --hash=sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa \ + --hash=sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9 \ + --hash=sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db \ + --hash=sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64 \ + --hash=sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505 \ + --hash=sha256:b39efa323140595abd3ecca8529d321ae50f55f3aa3ba9cc81ea56a6011953d5 \ + --hash=sha256:b47db11c2c3525083296069b98ac5221907455e989ae0c2e3008bde851921615 \ + --hash=sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f \ + --hash=sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866 \ + --hash=sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6 \ + --hash=sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561 \ + --hash=sha256:c83782480a4a9da4d0feb51950131ba32e12e70813848b3343f6e18c28a66838 \ + --hash=sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9 \ + --hash=sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7 \ + --hash=sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68 \ + --hash=sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8 \ + --hash=sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3 \ + --hash=sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e \ + --hash=sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a \ + --hash=sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d \ + --hash=sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4 \ + --hash=sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493 \ + --hash=sha256:fc1e275c2f1d97b1a6450b8b0ea3ebfa6e087a611c2b26cb2404d48588abab7b + # via + # pyopenssl + # webauthn +fastapi==0.137.2 \ + --hash=sha256:791d36261e916a98b25ac85ee591bc3db159394070f6d3d096d94fb378f60ce2 \ + --hash=sha256:b9d893bebc97dcfbdcb1917e88a292d062844ea19445a5fa4f7eb28c4baea9e3 + # via -r requirements.txt +h11==0.16.0 \ + --hash=sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1 \ + --hash=sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86 + # via + # -r requirements.txt + # httpcore + # uvicorn +httpcore==1.0.9 \ + --hash=sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55 \ + --hash=sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8 + # via httpx +httptools==0.8.0 \ + --hash=sha256:0770728beb05094c809b98e814edff5fef69d26ad7d21185f2f6d5884a0ba683 \ + --hash=sha256:0ea897f0c729581ebf72131a438a7932d9b14efef72d75ada966700cac3caaeb \ + --hash=sha256:159e9ab5f701ccd42e555a12f1ad8ff69702910fc1c996cf2bb66e5fcb7a231b \ + --hash=sha256:19d1ee275bb59ba2643ba9a3a1e51cc0c788caf2b8df506368e03f56fdd08527 \ + --hash=sha256:20b4aac66ff65f7db06a375808b78f42a94970aa22e826b3cb2b43eb09174124 \ + --hash=sha256:2a021c3a8e65cc125390d72f59b968afca3bdcaff25bd67965e0a055a14946ca \ + --hash=sha256:2c032fa028f46871ec7e1fc59fc15e8023eab3e6bbe6ece786a1611719a5d081 \ + --hash=sha256:2d689918c15a013c65ef52d9fd495d766893ab831a2c8d89f2ac5940a5df847c \ + --hash=sha256:384c17174464c8e873398b7af24f0b1f44d992c820328413951a625323155d77 \ + --hash=sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09 \ + --hash=sha256:48774d39cbb70e2b1f71f88852a3087ae1d3a1eb80482bb48c13067ab080c14f \ + --hash=sha256:52dd695b865fe96d9d2b16b64a895f3f57bf3cb064e8383cd3b5713a069e8085 \ + --hash=sha256:57278e6fa0424c42a8a3e454828ab4f0aff27b40cddf9679579b98c6dce6a376 \ + --hash=sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5 \ + --hash=sha256:5d7fa4ba7292c1139c0526f0b5aad507c6263c948206ea1b1cbca015c8af1b62 \ + --hash=sha256:5eb911c515b96ee44bbd861e42cbefc488681d450545b1d02127f6136e3a86f5 \ + --hash=sha256:614ceea8ea606848bece2338ac03b3ce5324bcb4be8dc7d377ed708012fa4db8 \ + --hash=sha256:6a43c9dd399758ccc0531acb0a3c4a6c299ee893ee9400e9c893b7bdcfae0681 \ + --hash=sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999 \ + --hash=sha256:7685df791fad561384bfb139e77fde27a1ffd93134e016f95a0db424ffbf77b1 \ + --hash=sha256:7b71e7d7031928c650e1006e6c03e911bf967f7c69c011d37d541c3e7bf55005 \ + --hash=sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d \ + --hash=sha256:88bdd940f2b5d487b4d032c6afa5489a7dc4694410d43de3c38c4fb3af0dc45d \ + --hash=sha256:88eead8ec8680a9f146c655bc88445a325bd7921cfd8194c7337e9467282427d \ + --hash=sha256:9518c406d7b310f05adb1a37f80acabac40504a575d7c0da6d3e365c695ac20d \ + --hash=sha256:9878eb2785ba5eb70631ad269b37976f73d647955e26c91d490eb8a4edfda4ba \ + --hash=sha256:9fc1644f415372cec4f8a5be3a64183737398f10dbb1263602a036427fe75247 \ + --hash=sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745 \ + --hash=sha256:a1b4c8e7a489a0d750d91894e9a8cdc295838f1924c0ca903ae993456fddec07 \ + --hash=sha256:a3b7387147361c3fd47a0bde763c5c91b5b4cd4dc9989b8ece84ff436c99843b \ + --hash=sha256:a6f21e2a3b0067bbe7f67e34cfd16276af556e5e52f4c7503be0cb5f90e905e4 \ + --hash=sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2 \ + --hash=sha256:b205e5f5523fa039679da0dfe5a10132b2a4abeae6a86fdd1ddc035f7f836557 \ + --hash=sha256:bbb8caadb2b742d293169d2b458b5c001ef70e3158704aa3d3ef9597624c5d1d \ + --hash=sha256:bf3b6f807c8541503cecfbb8a8dffb385640d0d96102f3d112aa8740f9b7c826 \ + --hash=sha256:c08ffe3e79756e0963cbc8fe410139f38a5884874b6f2e17761bef6563fdcd9b \ + --hash=sha256:c0d726cc107fceb7d45f978483b4b70dd8caa836f5914d3434bb18628eb73813 \ + --hash=sha256:c4a9f1707e4823d54dfec6c33fa3697d302aed536ed352a7ebb5a061ddb869d0 \ + --hash=sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150 \ + --hash=sha256:d76ad7b951387e3632c8716a9bb03ac5b45c5f16119aa409db0459520887944e \ + --hash=sha256:da684f2e1aa2ee9bdcb083f3f3a68c5956750b375bc5df864d3a5f0c42a40b77 \ + --hash=sha256:de1ed58a974e75d56560acc7e7fed01a454994429456f65209789992e41f2568 \ + --hash=sha256:de242a49b5d18e0a8776e654e9f6bf6d89f3875a5c35b425a0e7ce940feb3fd6 \ + --hash=sha256:df31ef5494f406ab6cf827b7e64a22841c6e2d654100e6a116ea15b46d02d5e8 \ + --hash=sha256:e93c227b595c6926c1acee96891dd9da4be338cfbe82e5cd3bb9d8dd7dc4ac0b \ + --hash=sha256:eb3028cca2fc0a6d720e52ef61d8ebb62fcbfeb1de56874546d858d3f25a26b7 \ + --hash=sha256:ed377e64805bdba4943c82717333f8f8603a13b09aff9cead2717c6c817fb168 \ + --hash=sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a \ + --hash=sha256:f256d6ce930c52ca1cb2a960b7da03548c454e7d28b06059ad41bfe789036ce0 \ + --hash=sha256:fe2a4c95aeba2209434e7b31172da572846cae8ca0bf1e7013e61b99fbbf5e72 + # via uvicorn +httpx==0.28.1 \ + --hash=sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc \ + --hash=sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad + # via -r requirements.txt +idna==3.18 \ + --hash=sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2 \ + --hash=sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848 + # via + # anyio + # httpx +packaging==26.2 \ + --hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \ + --hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661 + # via wheel +prometheus-client==0.25.0 \ + --hash=sha256:5e373b75c31afb3c86f1a52fa1ad470c9aace18082d39ec0d2f918d11cc9ba28 \ + --hash=sha256:d5aec89e349a6ec230805d0df882f3807f74fd6c1a2fa86864e3c2279059fed1 + # via -r requirements.txt +pyasn1==0.6.3 \ + --hash=sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf \ + --hash=sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde + # via + # pyasn1-modules + # webauthn +pyasn1-modules==0.4.2 \ + --hash=sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a \ + --hash=sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6 + # via webauthn +pycparser==3.0 \ + --hash=sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29 \ + --hash=sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992 + # via cffi +pydantic==2.13.4 \ + --hash=sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba \ + --hash=sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6 + # via fastapi +pydantic-core==2.46.4 \ + --hash=sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0 \ + --hash=sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262 \ + --hash=sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda \ + --hash=sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0 \ + --hash=sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e \ + --hash=sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b \ + --hash=sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594 \ + --hash=sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29 \ + --hash=sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2 \ + --hash=sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c \ + --hash=sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d \ + --hash=sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398 \ + --hash=sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d \ + --hash=sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3 \ + --hash=sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f \ + --hash=sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb \ + --hash=sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7 \ + --hash=sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5 \ + --hash=sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9 \ + --hash=sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462 \ + --hash=sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4 \ + --hash=sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b \ + --hash=sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d \ + --hash=sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df \ + --hash=sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2 \ + --hash=sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0 \ + --hash=sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519 \ + --hash=sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd \ + --hash=sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7 \ + --hash=sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac \ + --hash=sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6 \ + --hash=sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565 \ + --hash=sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898 \ + --hash=sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb \ + --hash=sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928 \ + --hash=sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6 \ + --hash=sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3 \ + --hash=sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a \ + --hash=sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596 \ + --hash=sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987 \ + --hash=sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e \ + --hash=sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d \ + --hash=sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712 \ + --hash=sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008 \ + --hash=sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd \ + --hash=sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1 \ + --hash=sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be \ + --hash=sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea \ + --hash=sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292 \ + --hash=sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33 \ + --hash=sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3 \ + --hash=sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4 \ + --hash=sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b \ + --hash=sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826 \ + --hash=sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac \ + --hash=sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7 \ + --hash=sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d \ + --hash=sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf \ + --hash=sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4 \ + --hash=sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc \ + --hash=sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15 \ + --hash=sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3 \ + --hash=sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b \ + --hash=sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914 \ + --hash=sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04 \ + --hash=sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c \ + --hash=sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b \ + --hash=sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9 \ + --hash=sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce \ + --hash=sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4 \ + --hash=sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a \ + --hash=sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f \ + --hash=sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424 \ + --hash=sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894 \ + --hash=sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9 \ + --hash=sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76 \ + --hash=sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201 \ + --hash=sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb \ + --hash=sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109 \ + --hash=sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4 \ + --hash=sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848 \ + --hash=sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526 \ + --hash=sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0 \ + --hash=sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01 \ + --hash=sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458 \ + --hash=sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e \ + --hash=sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba \ + --hash=sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a \ + --hash=sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39 \ + --hash=sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c \ + --hash=sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000 \ + --hash=sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b \ + --hash=sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf \ + --hash=sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4 \ + --hash=sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd \ + --hash=sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28 \ + --hash=sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9 \ + --hash=sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30 \ + --hash=sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983 \ + --hash=sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1 \ + --hash=sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76 \ + --hash=sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5 \ + --hash=sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4 \ + --hash=sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7 \ + --hash=sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c \ + --hash=sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066 \ + --hash=sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3 \ + --hash=sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02 \ + --hash=sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89 \ + --hash=sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50 \ + --hash=sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76 \ + --hash=sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49 \ + --hash=sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b \ + --hash=sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d \ + --hash=sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7 \ + --hash=sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4 \ + --hash=sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c \ + --hash=sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e \ + --hash=sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff \ + --hash=sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae + # via pydantic +pyjwt==2.13.0 \ + --hash=sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423 \ + --hash=sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728 + # via -r requirements.txt +pyopenssl==26.3.0 \ + --hash=sha256:46367f8f66b92271e6d218da9c87607e1ef5a0bc5c8dea5bb3db82f395c385a3 \ + --hash=sha256:589de7fae1c9ea670d18422ed00fc04da787bbde8e1454aea872aa57b49ad341 + # via webauthn +python-dotenv==1.2.2 \ + --hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \ + --hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3 + # via uvicorn +pyyaml==6.0.3 \ + --hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \ + --hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \ + --hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \ + --hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \ + --hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \ + --hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \ + --hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \ + --hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \ + --hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \ + --hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \ + --hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \ + --hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \ + --hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \ + --hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \ + --hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \ + --hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \ + --hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \ + --hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \ + --hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \ + --hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \ + --hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \ + --hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \ + --hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \ + --hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \ + --hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \ + --hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \ + --hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \ + --hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \ + --hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \ + --hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \ + --hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \ + --hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \ + --hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \ + --hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \ + --hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \ + --hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \ + --hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \ + --hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \ + --hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \ + --hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \ + --hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \ + --hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \ + --hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \ + --hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \ + --hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \ + --hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \ + --hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \ + --hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \ + --hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \ + --hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \ + --hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \ + --hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \ + --hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \ + --hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \ + --hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \ + --hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \ + --hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \ + --hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \ + --hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \ + --hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \ + --hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \ + --hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \ + --hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \ + --hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \ + --hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \ + --hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \ + --hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \ + --hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \ + --hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \ + --hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \ + --hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \ + --hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \ + --hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0 + # via uvicorn +redis==8.0.0 \ + --hash=sha256:a00c5355432051ac14e593b8b197fc76c887ee12d55a0984f69328a1115fdc49 \ + --hash=sha256:c938c18338585009f0bc310f4c7e4e4b4d37639356c4ac072cedf3af570c8dc7 + # via -r requirements.txt +starlette==1.3.1 \ + --hash=sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0 \ + --hash=sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6 + # via + # -r requirements.txt + # fastapi +typing-extensions==4.15.0 \ + --hash=sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466 \ + --hash=sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548 + # via + # anyio + # fastapi + # pydantic + # pydantic-core + # pyopenssl + # starlette + # typing-inspection +typing-inspection==0.4.2 \ + --hash=sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7 \ + --hash=sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464 + # via + # fastapi + # pydantic +uvicorn[standard]==0.49.0 \ + --hash=sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f \ + --hash=sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3 + # via -r requirements.txt +uvloop==0.22.1 \ + --hash=sha256:017bd46f9e7b78e81606329d07141d3da446f8798c6baeec124260e22c262772 \ + --hash=sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e \ + --hash=sha256:05e4b5f86e621cf3927631789999e697e58f0d2d32675b67d9ca9eb0bca55743 \ + --hash=sha256:0ae676de143db2b2f60a9696d7eca5bb9d0dd6cc3ac3dad59a8ae7e95f9e1b54 \ + --hash=sha256:1489cf791aa7b6e8c8be1c5a080bae3a672791fcb4e9e12249b05862a2ca9cec \ + --hash=sha256:17d4e97258b0172dfa107b89aa1eeba3016f4b1974ce85ca3ef6a66b35cbf659 \ + --hash=sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8 \ + --hash=sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad \ + --hash=sha256:286322a90bea1f9422a470d5d2ad82d38080be0a29c4dd9b3e6384320a4d11e7 \ + --hash=sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35 \ + --hash=sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289 \ + --hash=sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142 \ + --hash=sha256:3b7f102bf3cb1995cfeaee9321105e8f5da76fdb104cdad8986f85461a1b7b77 \ + --hash=sha256:40631b049d5972c6755b06d0bfe8233b1bd9a8a6392d9d1c45c10b6f9e9b2733 \ + --hash=sha256:481c990a7abe2c6f4fc3d98781cc9426ebd7f03a9aaa7eb03d3bfc68ac2a46bd \ + --hash=sha256:4a968a72422a097b09042d5fa2c5c590251ad484acf910a651b4b620acd7f193 \ + --hash=sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74 \ + --hash=sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0 \ + --hash=sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6 \ + --hash=sha256:535cc37b3a04f6cd2c1ef65fa1d370c9a35b6695df735fcff5427323f2cd5473 \ + --hash=sha256:53c85520781d84a4b8b230e24a5af5b0778efdb39142b424990ff1ef7c48ba21 \ + --hash=sha256:55502bc2c653ed2e9692e8c55cb95b397d33f9f2911e929dc97c4d6b26d04242 \ + --hash=sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705 \ + --hash=sha256:56a2d1fae65fd82197cb8c53c367310b3eabe1bbb9fb5a04d28e3e3520e4f702 \ + --hash=sha256:57df59d8b48feb0e613d9b1f5e57b7532e97cbaf0d61f7aa9aa32221e84bc4b6 \ + --hash=sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f \ + --hash=sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e \ + --hash=sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d \ + --hash=sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370 \ + --hash=sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4 \ + --hash=sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792 \ + --hash=sha256:80eee091fe128e425177fbd82f8635769e2f32ec9daf6468286ec57ec0313efa \ + --hash=sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079 \ + --hash=sha256:a592b043a47ad17911add5fbd087c76716d7c9ccc1d64ec9249ceafd735f03c2 \ + --hash=sha256:ac33ed96229b7790eb729702751c0e93ac5bc3bcf52ae9eccbff30da09194b86 \ + --hash=sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6 \ + --hash=sha256:b45649628d816c030dba3c80f8e2689bab1c89518ed10d426036cdc47874dfc4 \ + --hash=sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3 \ + --hash=sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21 \ + --hash=sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c \ + --hash=sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e \ + --hash=sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25 \ + --hash=sha256:c3e5c6727a57cb6558592a95019e504f605d1c54eb86463ee9f7a2dbd411c820 \ + --hash=sha256:c60ebcd36f7b240b30788554b6f0782454826a0ed765d8430652621b5de674b9 \ + --hash=sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88 \ + --hash=sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2 \ + --hash=sha256:ea721dd3203b809039fcc2983f14608dae82b212288b346e0bfe46ec2fab0b7c \ + --hash=sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c \ + --hash=sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42 + # via uvicorn +watchfiles==1.2.0 \ + --hash=sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9 \ + --hash=sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98 \ + --hash=sha256:027ae72bfdfd254862065d8b3e2a815c6ab9b1853ce41e6648ece84afd34a551 \ + --hash=sha256:03b14855c6f35539e2d95c442ae9530a75762f1e26567152b9ed05f96534a74d \ + --hash=sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7 \ + --hash=sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db \ + --hash=sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69 \ + --hash=sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242 \ + --hash=sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925 \ + --hash=sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f \ + --hash=sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5 \ + --hash=sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5 \ + --hash=sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427 \ + --hash=sha256:11743adfa510bfffebe97659fb280182b5c9b238708f667e866f308c3430dc19 \ + --hash=sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4 \ + --hash=sha256:204f299afcbd65918ab78dbc52626b0ae45e9d8cef403fdbf33ecf9e40eac66e \ + --hash=sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa \ + --hash=sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba \ + --hash=sha256:24b2405c0a46738dd9e1cf7135aa5dbdb9d42d024628651b3b13d5117e99f8df \ + --hash=sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c \ + --hash=sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906 \ + --hash=sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65 \ + --hash=sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c \ + --hash=sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c \ + --hash=sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30 \ + --hash=sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077 \ + --hash=sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374 \ + --hash=sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01 \ + --hash=sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33 \ + --hash=sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831 \ + --hash=sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9 \ + --hash=sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2 \ + --hash=sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b \ + --hash=sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f \ + --hash=sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658 \ + --hash=sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579 \ + --hash=sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5 \ + --hash=sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0 \ + --hash=sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7 \ + --hash=sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666 \ + --hash=sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5 \ + --hash=sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201 \ + --hash=sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103 \ + --hash=sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6 \ + --hash=sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8 \ + --hash=sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1 \ + --hash=sha256:77a0feab9af4c021c581f695258c642b3d10c5fd4c676e33a0d8606425d82631 \ + --hash=sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898 \ + --hash=sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d \ + --hash=sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44 \ + --hash=sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2 \ + --hash=sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5 \ + --hash=sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a \ + --hash=sha256:8c520725602756229f045b032a1ff33d7ef0f7404189d62f6c2438cb6d8ef6a1 \ + --hash=sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b \ + --hash=sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc \ + --hash=sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5 \ + --hash=sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377 \ + --hash=sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8 \ + --hash=sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add \ + --hash=sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281 \ + --hash=sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9 \ + --hash=sha256:a16ffe19bf5cf9f5edaa1ad1dd830c5a816e8feec430c522302ab55483a4b994 \ + --hash=sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0 \ + --hash=sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e \ + --hash=sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0 \ + --hash=sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28 \ + --hash=sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7 \ + --hash=sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55 \ + --hash=sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb \ + --hash=sha256:b62f042afde2dde21ec1d2c1a74361e804673df86f51e418a999c9acfe671b07 \ + --hash=sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb \ + --hash=sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4 \ + --hash=sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0 \ + --hash=sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e \ + --hash=sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4 \ + --hash=sha256:bb68bf4df85abebe5efddc53cf2075520f243a59868d9b3973278b23e76962a9 \ + --hash=sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06 \ + --hash=sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26 \ + --hash=sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7 \ + --hash=sha256:c16cb06dd17d43b9d185094268459eac92c9538356f050e55b54e82cf700e1d4 \ + --hash=sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3 \ + --hash=sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3 \ + --hash=sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838 \ + --hash=sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71 \ + --hash=sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488 \ + --hash=sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717 \ + --hash=sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d \ + --hash=sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44 \ + --hash=sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2 \ + --hash=sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b \ + --hash=sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2 \ + --hash=sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22 \ + --hash=sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6 \ + --hash=sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e \ + --hash=sha256:e1cfd51e97e13ff3bd047c140764d277fc9b95b7cb5da59e46a47d167adab310 \ + --hash=sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165 \ + --hash=sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5 \ + --hash=sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799 \ + --hash=sha256:eb72919d93e3a16fc451d3aa3d4b1698423daca1b382d3d959c9ac51297c12a8 \ + --hash=sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7 \ + --hash=sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379 \ + --hash=sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925 \ + --hash=sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72 \ + --hash=sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4 \ + --hash=sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08 \ + --hash=sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4 + # via uvicorn +webauthn==2.8.0 \ + --hash=sha256:357f068a9eaa95455d97e6aa4d89c9893f4fec2cff32bba74b0fc73b5cb743b7 \ + --hash=sha256:cb3a49abbddb3a757e60c58f8b2b10ca20ef451935b97dfd7e9beedaabff7b10 + # via -r requirements.txt +websockets==16.0 \ + --hash=sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c \ + --hash=sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a \ + --hash=sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe \ + --hash=sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e \ + --hash=sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec \ + --hash=sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1 \ + --hash=sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64 \ + --hash=sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3 \ + --hash=sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8 \ + --hash=sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206 \ + --hash=sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3 \ + --hash=sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156 \ + --hash=sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d \ + --hash=sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9 \ + --hash=sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad \ + --hash=sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2 \ + --hash=sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03 \ + --hash=sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8 \ + --hash=sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230 \ + --hash=sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8 \ + --hash=sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea \ + --hash=sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641 \ + --hash=sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957 \ + --hash=sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6 \ + --hash=sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6 \ + --hash=sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5 \ + --hash=sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f \ + --hash=sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00 \ + --hash=sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e \ + --hash=sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b \ + --hash=sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72 \ + --hash=sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39 \ + --hash=sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9 \ + --hash=sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79 \ + --hash=sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0 \ + --hash=sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac \ + --hash=sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35 \ + --hash=sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0 \ + --hash=sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5 \ + --hash=sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c \ + --hash=sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8 \ + --hash=sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1 \ + --hash=sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244 \ + --hash=sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3 \ + --hash=sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767 \ + --hash=sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a \ + --hash=sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d \ + --hash=sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd \ + --hash=sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e \ + --hash=sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944 \ + --hash=sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82 \ + --hash=sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d \ + --hash=sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4 \ + --hash=sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5 \ + --hash=sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904 \ + --hash=sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde \ + --hash=sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f \ + --hash=sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c \ + --hash=sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89 \ + --hash=sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da \ + --hash=sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4 + # via uvicorn +wheel==0.47.0 \ + --hash=sha256:212281cab4dff978f6cedd499cd893e1f620791ca6ff7107cf270781e587eced \ + --hash=sha256:cc72bd1009ba0cf63922e28f94d9d83b920aa2bb28f798a31d0691b02fa3c9b3 + # via blspy From f0bcfa48a751b14735e1d08f4f4b12850f2f3e47 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:17:09 +0000 Subject: [PATCH 4/9] CI: fix Trivy action pin to a valid tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit aquasecurity/trivy-action tags are v-prefixed (v0.29.0 … v0.36.0); the unprefixed 0.29.0 doesn't resolve, so the scan job failed at action setup. Pin to v0.36.0. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- .github/workflows/image-security.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/image-security.yml b/.github/workflows/image-security.yml index 90f4265b..8a17a7ce 100644 --- a/.github/workflows/image-security.yml +++ b/.github/workflows/image-security.yml @@ -41,7 +41,7 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max - name: Trivy scan — fail on fixable HIGH/CRITICAL (OS + Python deps) - uses: aquasecurity/trivy-action@0.29.0 + uses: aquasecurity/trivy-action@v0.36.0 with: image-ref: tensies:scan format: table From f77d045417227e2e5ef7337cc0d365f6913937aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:24:42 +0000 Subject: [PATCH 5/9] Dockerfile: apply OS security updates so the image scan passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new image-security scan (Trivy, fail on fixable HIGH/CRITICAL) flagged the base image's OS packages: a CRITICAL openssl heap overflow (CVE-2026-31789), several HIGH openssl RCE/UAF/DoS issues, and perl-base — all with fixed Debian versions available. The pinned python:3.12.8-slim-bookworm tag lags Debian's security repo, so a fresh build still ships these. Add `apt-get update && apt-get -y upgrade` (apt lists removed after) to the web runtime stage. Verified locally: openssl/libssl3 -> 3.0.20-1~deb12u2 and perl-base -> 5.36.0-7+deb12u3 (exactly the fixed versions), and a local Trivy run (HIGH,CRITICAL, --ignore-unfixed) now reports 0 findings. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- Dockerfile | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Dockerfile b/Dockerfile index 28cd2989..03a8c4c7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -86,6 +86,15 @@ ENV PYTHONDONTWRITEBYTECODE=1 \ WORKDIR /app +# Apply the latest Debian security updates to the base image's OS packages +# (openssl, perl-base, …). The pinned python base tag lags Debian's security +# repo, so a fresh build still ships fixable CVEs that the image-security scan +# gates on; upgrading in place clears them. apt lists are removed so nothing +# extra ships, and the setuid strip in the final RUN below covers any new bits. +RUN apt-get update \ + && apt-get -y upgrade \ + && rm -rf /var/lib/apt/lists/* + # Lift the compiled dependency set from the builder. No gcc/cmake/apt-lists ship. COPY --from=pybuild /opt/venv /opt/venv From c6c1e87cc62dd1a3979e2755b19825f69508bb61 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 02:35:48 +0000 Subject: [PATCH 6/9] =?UTF-8?q?Don't=20log=20the=20Redis=20URL=20=E2=80=94?= =?UTF-8?q?=20it=20carries=20the=20password=20inline=20(CodeQL=20high)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeQL "Clear-text logging of sensitive information" (high) flagged gamestore.py: the connect log emitted REDIS_URL verbatim, which embeds the password (redis://:PASSWORD@host). Surfaced once config._secret() made CodeQL treat REDIS_URL as sensitive, but it was a real leak — the password landed in the app logs on every startup. Drop the URL from the success log (log a plain "connected" line; the host is static config and the confirmation is the useful signal). The failure path keeps an actionable target via a new _redact() helper that strips credentials (scheme://host:port only) — used in the RuntimeError message, which is not a logging sink. No REDIS_URL-derived value reaches a log sink anymore. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- server/gamestore.py | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/server/gamestore.py b/server/gamestore.py index 3e708424..500b7373 100644 --- a/server/gamestore.py +++ b/server/gamestore.py @@ -20,6 +20,7 @@ import secrets import string import time +from urllib.parse import urlsplit import redis.asyncio as aioredis @@ -46,6 +47,22 @@ def _gkey(code: str) -> str: return f"game:{code}" +def _redact(url: str) -> str: + """A connection URL with any credentials stripped (scheme://host:port). + + REDIS_URL embeds its password inline, so it must never reach a log or error + string verbatim. This drops the userinfo and keeps only the non-sensitive + target, which is all an operator needs to diagnose a connection problem. + """ + try: + p = urlsplit(url) + host = p.hostname or "?" + netloc = f"{host}:{p.port}" if p.port else host + return f"{p.scheme}://{netloc}" if p.scheme else netloc + except ValueError: + return "" + + async def init() -> None: """Connect to Redis and register Lua scripts. Fails loudly if unreachable.""" global _r @@ -54,11 +71,13 @@ async def init() -> None: await _r.ping() except Exception as e: # noqa: BLE001 — surface a clear, actionable error raise RuntimeError( - f"Cannot reach Redis at {REDIS_URL}. Redis is required to run " + f"Cannot reach Redis at {_redact(REDIS_URL)}. Redis is required to run " f"Tensies; start one or set REDIS_URL. ({e})" ) from e _register_scripts() - log.info("gamestore connected redis=%s", REDIS_URL) + # Don't log REDIS_URL (or anything derived from it) — it carries the password + # inline. The host is static config; the connect confirmation is the signal. + log.info("gamestore connected to Redis") async def close() -> None: From d54be9bc0963aaf5efecbf51287c1491b8371440 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 03:14:41 +0000 Subject: [PATCH 7/9] docs: add game test-run log for the hardened prod stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core end-to-end suite (gameplay, multiplayer sync, pause, player + host reconnect, WebAuthn auth) run against docker-compose.prod.yml — read-only containers, file-based secrets, egress-split networks, nginx-unprivileged. 24/24 executed checks passed; hardening properties confirmed live (no Redis password in logs, read-only Postgres accepted the WebAuthn user write, data stores have no egress, no secret values in the web env). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- docs/test-runs/game/2026-06-21T03-11-00.md | 69 ++++++++++++++++++++++ docs/test-runs/game/README.md | 1 + 2 files changed, 70 insertions(+) create mode 100644 docs/test-runs/game/2026-06-21T03-11-00.md diff --git a/docs/test-runs/game/2026-06-21T03-11-00.md b/docs/test-runs/game/2026-06-21T03-11-00.md new file mode 100644 index 00000000..26bcf7de --- /dev/null +++ b/docs/test-runs/game/2026-06-21T03-11-00.md @@ -0,0 +1,69 @@ +# Test run — 2026-06-21T03:11:00 + +**Scope:** Core game suite run against the **HARDENED PROD STACK** (`docker-compose.prod.yml` +on branch `claude/docker-hardening-33204a`) — read-only containers, file-based secrets, +egress-split networks, `nginx-unprivileged` on 8080 published to host `8888`. Goal: confirm +the Docker security hardening did not break gameplay, multiplayer sync, reconnect, host +transfer, or pause. This is **not** the dev stack and not the full 34-step matrix — it is the +security-relevant subset driven against the actual prod images. + +Preflight: self-update — checked branch diff vs skill; hardening touched only infra/config +(`Dockerfile`, `docker-compose.prod.yml`, `server/config.py` `_secret`, `server/gamestore.py` +redaction) with **no** WS-protocol / state-field / UI changes, so no skill update needed. +Prior-run review — read 2026-06-17 (31/31) and 2026-06-15 (34/34); no outstanding FAILs. + +## Results + +| # | Result | Description | +|---|--------|-------------| +| 01 | ✅ PASS | Server health — all 7 services healthy; startup log shows `gamestore connected to Redis` with **no password** (redaction fix live) | +| 02 | ✅ PASS | Landing — clean, random placeholder, `window._state` exposed; single bundled `app-8966fd25.js`, no modulepreload (prod bundle from hardened nginx) | +| 03 | ✅ PASS | Create game (`MDSBE`) — host badge, start visible | +| 04 | ✅ PASS | Deep-link join + lobby sync both ways (cross-instance fan-out over Redis w/ file password) | +| 06 | ✅ PASS | Start + initial render — both tabs on `#game`, round 1, target 1, 0/10, synced | +| 08 | ✅ PASS | Single roll — `roll_count` 0→1, button disable→re-enable, settled 1708ms, no roll-ack hang | +| 11 | ✅ PASS | Multiplayer broadcast timing — guest saw roll **890ms** after click (in 800–2000ms reveal window; >200ms ⇒ delayed-broadcast correct) | +| 12 | ✅ PASS | Roll to win — Alpha "Winner" overlay, round 1, won in 16 rolls (`target-met`) | +| 14 | ✅ PASS | Round transition — round 1→2, target 1→2 (correct cycle), both tabs synced | +| 15 | ✅ PASS | Host pause toggle — host sees pause btn, guest's hidden (host-only); paused, roll disabled "Paused" | +| 16 | ✅ PASS | Pause status — "Everyone is here!"; guest pause overlay over live board ("Waiting for Alpha…") | +| 17 | ✅ PASS | Resume — menu closes, roll re-enabled; guest overlay closes | +| 18 | ✅ PASS | Player disconnect — host → loading "Waiting for Beta to reconnect…", Beta held disconnected | +| 19 | ✅ PASS | Player reconnect — reload bootstrap auto-reconnect; both restored (token verified via file `JWT_SECRET`) | +| 20 | ✅ PASS | Host disconnect + reconnect — guest waits, host role retained, host reload restores both | +| 23 | ✅ PASS | Console audit — 0 errors / 0 warnings on both instances | +| 24 | ✅ PASS | Virtual authenticator + sign-in screen | +| 25 | ✅ PASS | Sign-up (real WebAuthn) — `@TestAlpha` onboarding, JWT saved; **user row written to read-only Postgres** (data volume + file password) | +| 26 | ✅ PASS | Signed-in landing — name hidden, `@TestAlpha` pill | +| 28 | ✅ PASS | Sign-out — anonymous restored, token cleared | +| 29 | ✅ PASS | Sign-in (existing) — assertion verified vs stored credential, signed-in restored | +| H1 | ✅ PASS | Live hardening: data-store `internal` net has **no default route** (egress blocked) | +| H2 | ✅ PASS | Live hardening: all 7 containers `ReadonlyRootfs=true` | +| H3 | ✅ PASS | Live hardening: web env exposes only `*_FILE` pointers — **0** secret values | + +24/24 executed checks passed. + +## Findings +None. Every security-relevant gameplay path works on the hardened stack. + +## Notes +- Ran the prod stack as the single system-under-test the whole way (port 8888 via + `WEB_PUBLISH`), so the skill's dev→prod switch steps (30/34) are moot; the bundle-structure + checks (31/32) were satisfied inline at Step 02 (single hashed bundle, no modulepreload). +- Steps intentionally skipped as not security-hardening-relevant for this pass: 05/07 + (edge-case rejections), 09 (covered by 08+12), 10 (rate limit), 13 (sticky overlay), 21 + (animation pixels), 22 (multi-round flash). These are unaffected by the Docker changes. +- Host transfer: verified the immediate disconnected-but-not-dropped state + host reconnect; + the actual 60s `DISCONNECT_GRACE` role reassignment is the skill's optional slow check, not run. +- The headline hardening wins were all observed live: redaction fix removed the Redis password + from logs; read-only Postgres accepted the WebAuthn user write; egress split confirmed; + no secrets in the web environment. +- Images rebuilt fresh from branch HEAD (`c6c1e87`) with the proxy CA before the run, so the + apt-upgrade (OS CVE patch) and gamestore redaction were both in the images under test. + +## Watch next run +- If run again, exercise the rate-limit (Step 10) and multi-round overlay flash (Step 22) on + the hardened stack for completeness. +- Confirm the 60s host-transfer reassignment once, since host transfer touches `drop_player`. +- Docker daemon needed a manual containerd cleanup + restart this session (stale pid 690); + watch for that on resumed sessions. diff --git a/docs/test-runs/game/README.md b/docs/test-runs/game/README.md index a3b9b557..4f66a6f9 100644 --- a/docs/test-runs/game/README.md +++ b/docs/test-runs/game/README.md @@ -7,6 +7,7 @@ loop across two isolated Playwright instances. | Date | Scope | Result | Passed | Total | Highlight | |------|-------|--------|--------|-------|-----------| +| [2026-06-21T03:11:00](2026-06-21T03-11-00.md) | Game | ✅ PASS | 24 | 24 | Core suite vs the **hardened prod stack** (read-only containers, file secrets, egress split, nginx-unprivileged); WebAuthn user written to read-only Postgres; Redis password gone from logs; broadcast 890ms; 0 console errors | | [2026-06-17T20:30:00](2026-06-17T20-30-00.md) | Game | ✅ PASS | 31 | 31 | End Game feature (tap-to-confirm, overlay scoreboard, dismiss); overlay consistency 2995–3037ms across 6 rounds; prod bundle `app-be6e5702.js`; auth degraded to JWT (RP_ID mismatch) | | [2026-06-15T17:49:00](2026-06-15T17-49-00.md) | Game | ✅ PASS | 34 | 34 | First 34-step run with auth (WebAuthn CDP Virtual Authenticator); celebration-echo guard held (0 flashes across 20 overlays); all overlay durations 3002–3045ms | | [2026-06-11T08:45:00](2026-06-11T08-45-00.md) | Game | ✅ PASS | 28 | 28 | First full suite against rewrite-v2 (blank-canvas frontend, 25/25 pixel baselines at zero diff); all invariants held; known post-reveal `showFor()` race recurred once on prod (759ms, pre-existing — noted on Step 27) | From f1c3dc18c97e82b7d712d71e9097529413ff2a40 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 03:31:17 +0000 Subject: [PATCH 8/9] docs: add telemetry test-run log for the hardened prod stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full 18-step telemetry pipeline test run against docker-compose.prod.yml — token-gated /metrics, internal-only Prometheus, Grafana datasource password via $__file{} on a read-only container, file-based secrets. 18/18 passed: Prometheus scrapes the gated /metrics via the mounted token file over the egress-split network (rolls 99=99 exact), Postgres datasource connects, all 5 dashboards render error-free, 0 telemetry drops / 0 live-push failures. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- .../telemetry/2026-06-21T03-30-00.md | 71 +++++++++++++++++++ docs/test-runs/telemetry/README.md | 1 + 2 files changed, 72 insertions(+) create mode 100644 docs/test-runs/telemetry/2026-06-21T03-30-00.md diff --git a/docs/test-runs/telemetry/2026-06-21T03-30-00.md b/docs/test-runs/telemetry/2026-06-21T03-30-00.md new file mode 100644 index 00000000..5f2dd84a --- /dev/null +++ b/docs/test-runs/telemetry/2026-06-21T03-30-00.md @@ -0,0 +1,71 @@ +# Telemetry test run — 2026-06-21T03:30:00 + +**Scope:** Full 18-step telemetry pipeline test run against the **HARDENED PROD STACK** +(`docker-compose.prod.yml` on branch `claude/docker-hardening-33204a`) — read-only +containers, file-based secrets, egress-split networks, gated `/metrics`, Grafana datasource +password via `$__file{}`. Host-side checks adapted for the hardening: `/metrics` queried with +the bearer token, Prometheus (internal-only) reached via a throwaway container on +`tensies_internal`, Grafana logged in (anonymous disabled in prod). Goal: confirm the +security hardening did not break the event→Postgres→Prometheus pipeline, Grafana +dashboards/datasources, or Live channels. + +Preflight: self-update — no telemetry-relevant code changes on the branch (only `live.py` +reading `GRAFANA_PASS` via `_secret` and `datasources.yaml` using `$__file{}`; no new +events/metrics/channels/dashboards), so no skill update. Prior-run review — read +2026-05-30 (18/18); no outstanding FAILs. + +## Results + +| # | Result | Description | +|---|--------|-------------| +| 01 | ✅ PASS | Server + Prometheus health — `/metrics` correctly **401 without token**, 200 with (226 metrics); Prometheus scrape target `tensies → web:8000/metrics` **UP** via token file over the internal net | +| 02 | ✅ PASS | Grafana health (db ok), all 5 dashboards provisioned, both datasources present; **Postgres datasource "Database Connection OK"** (`$__file{}` password resolves on read-only Grafana) | +| 03 | ✅ PASS | Postgres — 7 migrations, all 10 core tables, events partition present | +| 04 | ✅ PASS | Baseline — clean zero counters, `games_active=0` | +| 05 | ✅ PASS | Game played (FFBBC, Telemetry+Monitor, 99 rolls/3 rounds); `games_active` 0→1; mid-game `live_games`/`live_players` populated | +| 06 | ✅ PASS | Event log — all 8 required types present; 99 rolls, 0 missing payload fields, max_matched=10 | +| 07 | ✅ PASS | Rollups — games `ended`/peak 2/round_count 4/99 rolls; target cycle 1→2→3; player_stats updated (3 wins) | +| 08 | ✅ PASS | Prometheus delta — `rolls_total=99` (exact match to Postgres), started/ended=1, sessions=2, `games_active` back to 0; Prometheus-stored value also 99 | +| 09 | ✅ PASS | live-games dashboard — 8 panels, 0 errors, only the 2 by-design live-only panels empty post-game | +| 10 | ✅ PASS | per-game dashboard — 9 panels, 0 errors, Postgres data visible (player names); only `live_games`-backed panels empty post-game | +| 11 | ✅ PASS | connections dashboard — 8 panels, 0 errors | +| 12 | ✅ PASS | gameplay-health dashboard — 8 panels, 0 errors | +| 13 | ✅ PASS | analytics dashboard — 11 panels, 0 errors, leaderboard populated | +| 14 | ✅ PASS | Dice fairness — all faces ratio 0.78–1.20, no anomaly (369 rolled values) | +| 15 | ✅ PASS | Timeline sanity — 0 anomalies across all checks | +| 16 | ✅ PASS | Self-metrics — 0 drops, 0 push failures, all queues drained, 119 batches written | +| 17 | ✅ PASS | Grafana Live push — `live_push_seconds_count=137`, 0 failures (web→Grafana over internal net) | +| 18 | ✅ PASS | Log audit — no tracebacks/errors in web or Postgres logs | + +18/18 passed. + +## Findings +None. The full telemetry pipeline works on the hardened stack. + +## Pipeline measurements +- Rolls in test game: 99 (Alpha 50, Beta 49) +- Postgres roll events: 99 — matches Prometheus `tensies_rolls_total` delta exactly (0%) +- Telemetry dropped events (new this run): 0 +- Live push failures (new this run): 0 +- Queue depth at end: writer=0, live=0 (q2=0, discord=0) +- Live push fires: 137; batches written: 119 +- Dice distribution: [1:67, 2:48, 3:64, 4:57, 5:74, 6:59] (max ratio 1.20x) + +## Notes +- This is the first telemetry run against the **hardened prod compose** (prior runs used the + dev stack). The hardening-specific surfaces all passed: token-gated `/metrics` (401→200), + Prometheus scraping the gated endpoint with the mounted token file over the egress-split + network, Grafana datasource password via `$__file{}` on a read-only container, and the + read-only web container's telemetry writer persisting to read-only Postgres. +- Step 10 per-game: a couple of panel-content assertions via `innerText` (`rolled N/10`, + "Dice distribution"/"Player wins" titles) did not match — a canvas/lazy-render artifact, not + a data problem (data confirmed in Postgres at Steps 6–7, datasource health OK, screenshot + shows panels). Not scored as a failure. +- `game_ended` fired with `reason="all_dropped"` after the clean DISCONNECT_GRACE expiry; + extra `host_transferred`(1) and `player_left`(4) events appeared from the dual-disconnect + end sequence — expected. + +## Watch next run +- The `innerText` panel assertions in Step 10 are brittle on canvas panels; consider asserting + via the Grafana query API instead of page text. +- Docker daemon needed a manual containerd cleanup + restart earlier this session. diff --git a/docs/test-runs/telemetry/README.md b/docs/test-runs/telemetry/README.md index e8b0f3ad..b1e165f7 100644 --- a/docs/test-runs/telemetry/README.md +++ b/docs/test-runs/telemetry/README.md @@ -8,6 +8,7 @@ five Grafana dashboards, dice fairness, timeline sanity, and anomaly detection. | Date | Scope | Result | Passed | Warned | Total | Highlight | |------|-------|--------|--------|--------|-------|-----------| +| [2026-06-21T03:30:00](2026-06-21T03-30-00.md) | Telemetry | ✅ PASS | 18 | 0 | 18 | First run vs the **hardened prod stack**; token-gated `/metrics` (401→200), Prometheus scrapes it via token file over the egress-split net, Grafana `$__file{}` Postgres datasource OK on read-only container; rolls 99=99 exact, 0 drops/failures | | [2026-05-30T18:03:09](2026-05-30T18-03-09.md) | Telemetry | ✅ PASS | 18 | 0 | 18 | First 18/18 clean run; Step 9 WARN eliminated — "Active games"/"Round progress" query live_games (cleared on end), now asserted live during game in Step 5 instead | | [2026-05-30T17:49:44](2026-05-30T17-49-44.md) | Telemetry | ⚠️ WARN | 17 | 1 | 18 | Clean run; only WARN is expected live-games No-data (3rd recurrence); 0% roll delta, 0 drops | | [2026-05-30T16:15:00](2026-05-30T16-15-00.md) | Telemetry | ⚠️ WARN | 15 | 3 | 18 | Known WARNs recur (player_count=0, live-games No data); pre-existing game contaminated games_ended delta | From e13391ae4fb2b90a9fef7efef4c2086027fa28fb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 04:00:41 +0000 Subject: [PATCH 9/9] docs: add scale/restart/host-transfer follow-up results to the hardened-stack log Three additional checks on the hardened prod stack, all PASS: - Host transfer after 60s DISCONNECT_GRACE: role reassigns to the remaining player. - Read-only Postgres restart on an existing volume: comes up via the non-initdb path under ReadonlyRootfs with data intact and password-from-file. - Scale to 3 read-only/capped replicas: cross-instance sharing works (create on web-2, join on web-1, roll fans out across them via Redis pub/sub over the egress-split internal net); 0 restarts / no OOM. A single-IP synthetic flood was correctly throttled by the per-IP abuse caps (not a throughput benchmark). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015mjorQqBtf4Jm5VUvVRLhX --- docs/test-runs/game/2026-06-21T03-11-00.md | 28 +++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/docs/test-runs/game/2026-06-21T03-11-00.md b/docs/test-runs/game/2026-06-21T03-11-00.md index 26bcf7de..66d4ae50 100644 --- a/docs/test-runs/game/2026-06-21T03-11-00.md +++ b/docs/test-runs/game/2026-06-21T03-11-00.md @@ -64,6 +64,32 @@ None. Every security-relevant gameplay path works on the hardened stack. ## Watch next run - If run again, exercise the rate-limit (Step 10) and multi-round overlay flash (Step 22) on the hardened stack for completeness. -- Confirm the 60s host-transfer reassignment once, since host transfer touches `drop_player`. - Docker daemon needed a manual containerd cleanup + restart this session (stale pid 690); watch for that on resumed sessions. + +## Follow-up hardening tests (scale / read-only restart / host transfer) — all PASS + +Run after the main suite + the telemetry suite, on the same hardened prod stack. + +- **Host transfer after 60s `DISCONNECT_GRACE` — PASS.** 2-player game (HostA + GuestB); + dropped HostA's socket and waited out the grace. The drop fired, HostA was removed + (player_count 1), and the host role **reassigned to GuestB** (`host == GuestB.id`); the new + host gained the host-only pause control. Closes the "optional slow check" left open above. +- **Read-only Postgres restart (existing volume) — PASS.** `docker compose restart` of the + whole stack against the existing volumes. Postgres came back up via the existing-data path + ("Database directory appears to contain a database; Skipping initialization" → "ready to + accept connections") under `ReadonlyRootfs=true` with the password still from file (0 in + env); all data persisted (games/events intact); web reconnected to Redis and served. The + non-initdb entrypoint path works read-only with the minimal cap_add set. +- **Scale to 3 replicas + cross-instance sharing — PASS.** `--scale web=3` (all + `ReadonlyRootfs=true`, pids=256, mem=512M), nginx reloaded. A controlled 2-player game + proved cross-instance sharing: `create` for game YHPMT landed on **web-2**, the `join` on + **web-1**, yet both clients saw the full roster AND a roll on web-2's client fanned out to + web-1's client (`roll_count` 0→1) — Redis state + pub/sub fanout work over the egress-split + internal network. No OOM and **0 restarts** on any replica across all tests. +- **Load behaviour note (not a failure):** a synthetic load flood (`loadtest.py` via nginx, + 50/150 games) came from a single source IP and was **correctly throttled by the per-IP abuse + caps** (`CREATE_RATE` — "RATE LIMIT" logged across all 3 replicas). That's the security + control working as intended; it means a single-IP flood isn't a clean throughput/leak + benchmark. For a real throughput run, raise `MAX_CONNECTIONS_PER_IP`/`CREATE_RATE_*` or vary + `X-Forwarded-For` per client. `games_active` did drain to 0 after the run.