diff --git a/dashboard/dashboard.py b/dashboard/dashboard.py index c46ab52..9db3d56 100644 --- a/dashboard/dashboard.py +++ b/dashboard/dashboard.py @@ -15,6 +15,7 @@ POLL_INTERVAL (default 60), CACHE_TTL (default 30). import html import json import os +import re import sys import time import urllib.error @@ -25,6 +26,21 @@ DRONE_URL = os.environ.get("DRONE_URL", "https://drone.ci.commoninternet.net") CI_REPO = os.environ.get("CI_REPO", "recipe-maintainers/cc-ci") CACHE_TTL = int(os.environ.get("CACHE_TTL", "30")) +# Phase 3 (R3/R6/U2.3): per-run artifacts (results.json, summary card PNG, app screenshot, level +# badge) written by run_recipe_ci.py under this host dir, bind-mounted read-only into the dashboard +# container (see nix/modules/dashboard.nix). Served at the stable URL /runs//. +CCCI_RUNS_DIR = os.environ.get("CCCI_RUNS_DIR", "/var/lib/cc-ci-runs") +# Strict allow-list of servable filenames → content type. NOTHING outside this set is served, so the +# route cannot be used to read arbitrary files even before the path-traversal guard. +_RUN_FILES = { + "results.json": "application/json", + "summary.png": "image/png", + "screenshot.png": "image/png", + "badge.svg": "image/svg+xml", + "summary.html": "text/html; charset=utf-8", +} +_RUN_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$") + def _read(path): with open(path) as fh: @@ -159,6 +175,22 @@ def render_badge(recipe, status): {html.escape(msg)}""" +def serve_run_file(run_id, fname): + """Resolve a whitelisted per-run artifact to (content_type, bytes), or None if it must not / can + not be served. Defends against path traversal three ways: the filename must be in the explicit + allow-list (so no arbitrary name), the run_id must match a conservative charset (no `/`, no `..`), + and the realpath of the target must still live inside CCCI_RUNS_DIR. Read-only.""" + ctype = _RUN_FILES.get(fname) + if ctype is None or not _RUN_ID_RE.match(run_id or ""): + return None + base = os.path.realpath(CCCI_RUNS_DIR) + real = os.path.realpath(os.path.join(base, run_id, fname)) + if not (real == base or real.startswith(base + os.sep)) or not os.path.isfile(real): + return None + with open(real, "rb") as fh: + return ctype, fh.read() + + class Handler(BaseHTTPRequestHandler): def _send(self, code, body, ctype="text/html; charset=utf-8"): data = body.encode() if isinstance(body, str) else body @@ -177,6 +209,15 @@ class Handler(BaseHTTPRequestHandler): row = next((r for r in recipes_cached() if r["recipe"] == recipe), None) status = row["status"] if row else "unknown" return self._send(200, render_badge(recipe, status), "image/svg+xml") + if path.startswith("/runs/"): + # /runs// — stable URL for a run's results.json / summary.png / screenshot / + # badge (R3/R6). Whitelisted + traversal-guarded by serve_run_file. + parts = path[len("/runs/") :].split("/") + if len(parts) == 2: + got = serve_run_file(parts[0], parts[1]) + if got is not None: + return self._send(200, got[1], got[0]) + return self._send(404, "not found", "text/plain") if path == "/": return self._send(200, render_overview(recipes_cached())) return self._send(404, "not found", "text/plain") diff --git a/nix/modules/dashboard.nix b/nix/modules/dashboard.nix index 5c2213d..b5c2bfd 100644 --- a/nix/modules/dashboard.nix +++ b/nix/modules/dashboard.nix @@ -37,8 +37,17 @@ let - CI_REPO=recipe-maintainers/cc-ci - DASH_LISTEN=0.0.0.0:8080 - DRONE_TOKEN_FILE=/run/secrets/drone_token + - CCCI_RUNS_DIR=/var/lib/cc-ci-runs secrets: - drone_token + # Phase 3 (U2.3): the per-run artifacts (results.json, summary.png, screenshot.png, badge.svg) + # the runner writes under /var/lib/cc-ci-runs are bind-mounted READ-ONLY so the dashboard can + # serve them at /runs//. Read-only: the dashboard never writes run artifacts. + volumes: + - type: bind + source: /var/lib/cc-ci-runs + target: /var/lib/cc-ci-runs + read_only: true networks: - proxy deploy: