telemetry: synthetic:true on canary refusals (keyed, not a bare flag)
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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user