telemetry: synthetic:true on canary refusals (keyed, not a bare flag)
All checks were successful
check / gate (push) Successful in 25s
canary / probe (push) Successful in 7s

The canary deliberately sends forged tokens every 10 min; those refusal
rows read as attacks. It now sends X-Windy-Synthetic carrying a shared
secret (Gitea repo secret CANARY_SYNTHETIC_KEY = WINDYGIT_SYNTHETIC_KEY in
Veron .env); the API marks the row synthetic only on a constant-time
match, so an attacker cannot label their own refusals synthetic to hide.
synthetic is declared on forge.auth.failed (Telemetry Boss, UPDATE 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-23 11:54:56 -04:00
parent 90643fe48e
commit 1c3b5b0638
6 changed files with 54 additions and 6 deletions

View File

@@ -45,4 +45,5 @@ jobs:
CANARY_LOGIN_EMAIL: ${{ secrets.CANARY_LOGIN_EMAIL }} CANARY_LOGIN_EMAIL: ${{ secrets.CANARY_LOGIN_EMAIL }}
CANARY_LOGIN_PASSWORD: ${{ secrets.CANARY_LOGIN_PASSWORD }} CANARY_LOGIN_PASSWORD: ${{ secrets.CANARY_LOGIN_PASSWORD }}
CANARY_ALERT_TO: ${{ secrets.CANARY_ALERT_TO }} CANARY_ALERT_TO: ${{ secrets.CANARY_ALERT_TO }}
CANARY_SYNTHETIC_KEY: ${{ secrets.CANARY_SYNTHETIC_KEY }}
run: python3 scripts/canary.py run: python3 scripts/canary.py

View File

@@ -71,6 +71,9 @@ class Settings(BaseSettings):
# root-only /etc/windygit/telemetry.env on Veron, never in the repo. # root-only /etc/windygit/telemetry.env on Veron, never in the repo.
windygit_telemetry_token: str = "" windygit_telemetry_token: str = ""
telemetry_ingest_url: str = "https://admin.windyword.ai/v1/events" 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 # Internal callers (the Cloud portal calling /internal/*). A first-class
# caller class, not a bypass: unset means service calls are REFUSED. # caller class, not a bypass: unset means service calls are REFUSED.

View File

@@ -27,7 +27,7 @@ from api.app.providers.registry import (
R2Provider, R2Provider,
) )
from api.app.routes import health, repos, webhooks 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( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -47,9 +47,7 @@ def _refuse_kit_zero(settings) -> None:
if not settings.is_production: if not settings.is_production:
return return
try: try:
local_ips = { local_ips = {info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None)}
info[4][0] for info in socket.getaddrinfo(socket.gethostname(), None)
}
except socket.gaierror: except socket.gaierror:
return return
if settings.kit0_host in local_ips: 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), caller=caller_class(request.headers),
route=getattr(route, "path", None), route=getattr(route, "path", None),
upstream_status=getattr(exc, "upstream_status", 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) return JSONResponse(status_code=exc.status_code, content=exc.detail)

View File

@@ -65,6 +65,14 @@ def _iso(epoch: float) -> str:
return datetime.fromtimestamp(epoch, UTC).isoformat().replace("+00:00", "Z") 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: def caller_class(headers) -> str:
"""Declared values: anonymous_human | anonymous_agent | unknown.""" """Declared values: anonymous_human | anonymous_agent | unknown."""
from api.app.ept import looks_like_ept from api.app.ept import looks_like_ept
@@ -145,10 +153,16 @@ class Telemetry:
caller: str, caller: str,
route: str | None = None, route: str | None = None,
upstream_status: int | None = None, upstream_status: int | None = None,
synthetic: bool = False,
) -> None: ) -> None:
if code not in AUTH_CODES: if code not in AUTH_CODES:
return 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: if route:
meta["route"] = route meta["route"] = route
if upstream_status is not None: if upstream_status is not None:

View File

@@ -14,7 +14,7 @@ from fastapi import Depends, FastAPI
from api.app import telemetry as tmod from api.app import telemetry as tmod
from api.app.errors import RepairPointer 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: 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"}) tmod.caller_class({"authorization": "Bearer eyJhbGciOiJSUzI1NiJ9.e30.x"})
== "anonymous_human" == "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

View File

@@ -73,6 +73,10 @@ class Check:
def _probe(c: Check) -> Result: def _probe(c: Check) -> Result:
data = json.dumps(c.body).encode() if c.body else None data = json.dumps(c.body).encode() if c.body else None
headers = {"User-Agent": "windy-git-canary/1.0", **c.headers} 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: if data:
headers["Content-Type"] = "application/json" headers["Content-Type"] = "application/json"
req = urllib.request.Request(c.url, data=data, method=c.method, headers=headers) req = urllib.request.Request(c.url, data=data, method=c.method, headers=headers)