diff --git a/.gitea/workflows/canary.yml b/.gitea/workflows/canary.yml index 9fb0ba7..241de27 100644 --- a/.gitea/workflows/canary.yml +++ b/.gitea/workflows/canary.yml @@ -45,4 +45,5 @@ jobs: CANARY_LOGIN_EMAIL: ${{ secrets.CANARY_LOGIN_EMAIL }} CANARY_LOGIN_PASSWORD: ${{ secrets.CANARY_LOGIN_PASSWORD }} CANARY_ALERT_TO: ${{ secrets.CANARY_ALERT_TO }} + CANARY_SYNTHETIC_KEY: ${{ secrets.CANARY_SYNTHETIC_KEY }} run: python3 scripts/canary.py diff --git a/api/app/config.py b/api/app/config.py index da32d66..b1b1ddb 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -71,6 +71,9 @@ class Settings(BaseSettings): # root-only /etc/windygit/telemetry.env on Veron, never in the repo. windygit_telemetry_token: str = "" telemetry_ingest_url: str = "https://admin.windyword.ai/v1/events" + # Shared with our canary (Gitea repo secret CANARY_SYNTHETIC_KEY). A request + # carrying it in X-Windy-Synthetic is our own tooling -> synthetic:true. + windygit_synthetic_key: str = "" # Internal callers (the Cloud portal calling /internal/*). A first-class # caller class, not a bypass: unset means service calls are REFUSED. diff --git a/api/app/main.py b/api/app/main.py index c59aa7a..4656744 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -27,7 +27,7 @@ from api.app.providers.registry import ( R2Provider, ) from api.app.routes import health, repos, webhooks -from api.app.telemetry import Telemetry, caller_class +from api.app.telemetry import Telemetry, caller_class, is_synthetic logging.basicConfig( level=logging.INFO, @@ -47,9 +47,7 @@ def _refuse_kit_zero(settings) -> None: if not settings.is_production: return try: - local_ips = { - info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None) - } + local_ips = {info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None)} except socket.gaierror: return if settings.kit0_host in local_ips: @@ -169,6 +167,11 @@ async def _repair_pointer_handler(request: Request, exc: RepairPointer) -> JSONR caller=caller_class(request.headers), route=getattr(route, "path", None), upstream_status=getattr(exc, "upstream_status", None), + synthetic=is_synthetic( + request.headers, request.app.state.settings.windygit_synthetic_key + ) + if hasattr(request.app.state, "settings") + else False, ) return JSONResponse(status_code=exc.status_code, content=exc.detail) diff --git a/api/app/telemetry.py b/api/app/telemetry.py index dcea56f..e363e47 100644 --- a/api/app/telemetry.py +++ b/api/app/telemetry.py @@ -65,6 +65,14 @@ def _iso(epoch: float) -> str: return datetime.fromtimestamp(epoch, UTC).isoformat().replace("+00:00", "Z") +def is_synthetic(headers, key: str) -> bool: + """Our own tooling proves itself with the shared key; a bare header proves nothing.""" + import hmac + + presented = headers.get("x-windy-synthetic") or "" + return bool(key) and bool(presented) and hmac.compare_digest(presented, key) + + def caller_class(headers) -> str: """Declared values: anonymous_human | anonymous_agent | unknown.""" from api.app.ept import looks_like_ept @@ -145,10 +153,16 @@ class Telemetry: caller: str, route: str | None = None, upstream_status: int | None = None, + synthetic: bool = False, ) -> None: if code not in AUTH_CODES: return - meta: dict = {"code": code, "http_status": int(http_status), "caller": caller} + meta: dict = { + "code": code, + "http_status": int(http_status), + "caller": caller, + "synthetic": bool(synthetic), + } if route: meta["route"] = route if upstream_status is not None: diff --git a/api/tests/test_telemetry.py b/api/tests/test_telemetry.py index 5abb8c1..e307318 100644 --- a/api/tests/test_telemetry.py +++ b/api/tests/test_telemetry.py @@ -14,7 +14,7 @@ from fastapi import Depends, FastAPI from api.app import telemetry as tmod from api.app.errors import RepairPointer -DECLARED_AUTH_KEYS = {"code", "http_status", "caller", "route", "upstream_status"} +DECLARED_AUTH_KEYS = {"code", "http_status", "caller", "route", "upstream_status", "synthetic"} def _app(tel: tmod.Telemetry) -> FastAPI: @@ -140,3 +140,26 @@ def test_caller_classes_are_the_declared_three(): tmod.caller_class({"authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.e30.x"}) == "anonymous_human" ) + + +@pytest.mark.asyncio +async def test_canary_refusals_are_marked_synthetic_only_with_the_real_key(): + from types import SimpleNamespace + + async def refusal(headers): + tel = _tel() + app = _app(tel) + app.state.settings = SimpleNamespace(windygit_synthetic_key="k3y") + await _get(app, "/api/v1/repos/x/grants", headers) + return [e for e in tel.buffer if e["event_type"] == "forge.auth.failed"][0]["metadata"][ + "synthetic" + ] + + assert await refusal({"X-Windy-Synthetic": "k3y"}) is True + assert await refusal({"X-Windy-Synthetic": "guess"}) is False # an attacker can't hide + assert await refusal({}) is False + + +def test_synthetic_needs_a_configured_key(): + assert tmod.is_synthetic({"x-windy-synthetic": ""}, "") is False + assert tmod.is_synthetic({"x-windy-synthetic": "anything"}, "") is False diff --git a/scripts/canary.py b/scripts/canary.py index 8d9f616..1d50132 100755 --- a/scripts/canary.py +++ b/scripts/canary.py @@ -73,6 +73,10 @@ class Check: def _probe(c: Check) -> Result: data = json.dumps(c.body).encode() if c.body else None headers = {"User-Agent": "windy-git-canary/1.0", **c.headers} + # Mark our own probes so the ledger can tell a canary forgery from an attack. + # A shared secret, not a flag: a bare header would let an attacker hide. + if os.environ.get("CANARY_SYNTHETIC_KEY"): + headers["X-Windy-Synthetic"] = os.environ["CANARY_SYNTHETIC_KEY"] if data: headers["Content-Type"] = "application/json" req = urllib.request.Request(c.url, data=data, method=c.method, headers=headers)