diff --git a/.env.example b/.env.example index 7d0783f98d..c8550f3339 100644 --- a/.env.example +++ b/.env.example @@ -164,12 +164,23 @@ GITTENSORY_REVIEW_DRAFT=false # --- Gittensory Orb (#1219; opt-in outcome signal collection) --- # Run GET /orb/setup to create the Orb GitHub App (read-only; separate from the main App). # Credentials are written to /data/gittensory-orb.env on callback — load them here. +# +# SECURITY MODEL (this image is meant to be self-hosted by many independent maintainers): +# • The image bakes NO secrets. Every operator creates their OWN Orb App via /orb/setup, so the +# ORB_* secrets below are unique to YOUR instance and live only in YOUR /data — never shared, +# never sent to the collector. gittensory's own App secrets are never in the image. +# • Export to the central collector uses NO shared key: repo/PR identifiers are HMAC-anonymized +# with YOUR ORB_WEBHOOK_SECRET (so even the collector operator can't de-anonymize them), and the +# collector accepts the batch as untrusted, rate-limited, aggregate-only telemetry. Nothing in the +# container, if leaked, can compromise the collector, other operators, or the main App. +# • Prefer the *_FILE convention for the private key (mount a file, not an inline env value): +# ORB_PRIVATE_KEY_FILE=/run/secrets/orb_private_key → the server reads it into ORB_PRIVATE_KEY. # ORB_APP_ID= # App ID from /orb/setup callback # ORB_APP_SLUG= # App slug (human-readable name) -# ORB_WEBHOOK_SECRET= # secret from /orb/setup callback — signs /orb/webhook requests -# ORB_PRIVATE_KEY= # PEM from /orb/setup callback (JSON-stringified) +# ORB_WEBHOOK_SECRET= # secret from /orb/setup callback — signs /orb/webhook + anonymizes export +# ORB_PRIVATE_KEY= # PEM from /orb/setup callback (JSON-stringified); prefer ORB_PRIVATE_KEY_FILE # ORB_ENABLED=false # master switch: set to true to enable collection (default off) # ORB_AIR_GAP=false # set to true to keep all data local — never send to the collector # ORB_ANONYMIZE=true # HMAC-hash repo names before export (default true; false = raw names) -# ORB_COLLECTOR_URL=https://orb.gittensory.app/v1/ingest # central collector URL (set by default; override as needed) +# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own) # ORB_SETUP_OUTPUT_PATH=/data/gittensory-orb.env # where /orb/setup/callback writes the credentials file diff --git a/src/auth/rate-limit.ts b/src/auth/rate-limit.ts index 300fba020d..79dd046a29 100644 --- a/src/auth/rate-limit.ts +++ b/src/auth/rate-limit.ts @@ -96,6 +96,9 @@ export async function enforceRateLimit(c: Context<{ Bindings: Env }>, routeClass export function routeClassForPath(path: string): RateLimitClass { if (path === "/v1/github/webhook") return "strict"; + // Orb telemetry ingest: unauthenticated + write, accepting anonymized batches from untrusted + // self-host instances. Strict (10/min per IP) caps abuse — legitimate instances export hourly. + if (path === "/v1/orb/ingest") return "strict"; if (path === "/v1/auth/session" || path === "/v1/auth/logout") return "normal"; if (path.startsWith("/v1/auth/")) return "strict"; if ( diff --git a/src/selfhost/orb-collector.ts b/src/selfhost/orb-collector.ts index 83fec8bb0f..d062323853 100644 --- a/src/selfhost/orb-collector.ts +++ b/src/selfhost/orb-collector.ts @@ -4,7 +4,7 @@ // // Collection is always local (DB only). Export to the central collector is opt-in: // ORB_ENABLED=true — activates collection (off by default) -// ORB_COLLECTOR_URL= — endpoint to export batches to (default: https://orb.gittensory.app/v1/ingest) +// ORB_COLLECTOR_URL= — endpoint to export batches to (default: gittensory's hosted collector) // ORB_AIR_GAP=true — keep all events local, never send externally // ORB_ANONYMIZE=true — HMAC-hash repo/owner before export (default: true) // @@ -92,7 +92,10 @@ export async function exportOrbBatch( if (!orbEnabled()) return 0; if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0; - const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://orb.gittensory.app/v1/ingest"; + // gittensory's hosted collector (the deployed /v1/orb/ingest receiver). No shared secret is sent: + // the batch is anonymized (HMAC of each operator's OWN ORB_WEBHOOK_SECRET) and accepted as untrusted, + // rate-limited telemetry. Override only to point at your own self-hosted collector. + const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest"; const secret = process.env.ORB_WEBHOOK_SECRET ?? ""; const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false"; diff --git a/test/unit/auth.test.ts b/test/unit/auth.test.ts index 9df9a2526c..98833e887c 100644 --- a/test/unit/auth.test.ts +++ b/test/unit/auth.test.ts @@ -80,6 +80,7 @@ describe("private-beta auth and rate limiting", () => { it("classifies rate-limit route costs", () => { expect(routeClassForPath("/v1/github/webhook")).toBe("strict"); + expect(routeClassForPath("/v1/orb/ingest")).toBe("strict"); // open telemetry ingest — abuse-capped per IP expect(routeClassForPath("/v1/auth/github/device/start")).toBe("strict"); expect(routeClassForPath("/v1/local/branch-analysis")).toBe("expensive"); expect(routeClassForPath("/v1/scoring/preview")).toBe("expensive"); diff --git a/test/unit/selfhost-orb-collector.test.ts b/test/unit/selfhost-orb-collector.test.ts index 20e0e0dcfa..8076617e45 100644 --- a/test/unit/selfhost-orb-collector.test.ts +++ b/test/unit/selfhost-orb-collector.test.ts @@ -147,6 +147,15 @@ describe("exportOrbBatch()", () => { expect(await exportOrbBatch(db, 200, async () => new Response(null, { status: 200 }))).toBe(0); }); + it("defaults to gittensory's hosted collector URL when ORB_COLLECTOR_URL is unset (regression: dead orb.gittensory.app)", async () => { + delete process.env.ORB_COLLECTOR_URL; + const db = makeDb(); + await recordOrbEvent(db, { repo: "o/r", pr_number: 1, head_sha: "sha", outcome: "merged" }); + let capturedUrl: string | undefined; + await exportOrbBatch(db, 200, async (url) => { capturedUrl = String(url); return new Response(null, { status: 200 }); }); + expect(capturedUrl).toBe("https://gittensory-api.aethereal.dev/v1/orb/ingest"); + }); + it("exports pending events and marks them as exported", async () => { const db = makeDb(); await recordOrbEvent(db, { repo: "owner/repo", pr_number: 1, head_sha: "sha1", outcome: "merged", gate_verdict: "approve" });