From ac53908c26c14efaa3e23fe29d8ea2605ca582c9 Mon Sep 17 00:00:00 2001 From: ghost <49853598+JSONbored@users.noreply.github.com> Date: Wed, 24 Jun 2026 14:35:37 -0700 Subject: [PATCH] fix(orb): require anonymization secret for export --- .env.example | 6 ++--- src/selfhost/orb-collector.ts | 4 ++++ test/unit/selfhost-orb-collector.test.ts | 29 +++++++++++++++++++++--- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 3cf4246534..a26c9e49e0 100644 --- a/.env.example +++ b/.env.example @@ -164,8 +164,8 @@ GITTENSORY_REVIEW_DRAFT=false # --- Gittensory Orb (#1255; opt-in fleet-calibration export) --- # Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from # self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App -# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an -# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard. +# already records de-noised outcomes (merged/closed + reversals) locally — set ORB_ENABLED plus a +# stable per-instance ORB_WEBHOOK_SECRET to ship an anonymized signal to gittensory's collector. # # SECURITY MODEL (this image is self-hosted by many independent maintainers): # • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET @@ -173,7 +173,7 @@ GITTENSORY_REVIEW_DRAFT=false # • Export carries NO shared key. 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 any App. # ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off) -# ORB_WEBHOOK_SECRET= # the per-instance HMAC key used to anonymize repo/PR identifiers +# ORB_WEBHOOK_SECRET=<32+ char stable random string> # required when ORB_ANONYMIZE=true; per-instance HMAC key # ORB_AIR_GAP=false # set to true to compute locally but never send to the collector # ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names) # ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own) diff --git a/src/selfhost/orb-collector.ts b/src/selfhost/orb-collector.ts index e7c16c79c2..81e31aa488 100644 --- a/src/selfhost/orb-collector.ts +++ b/src/selfhost/orb-collector.ts @@ -134,6 +134,10 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t 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"; + if (anonymize && secret.trim().length < 32) { + incr("gittensory_orb_export_errors_total", { reason: "missing_anonymization_secret" }); + return 0; + } const instance = instanceId(); // Read this instance's export watermark (resumes where the last run left off). diff --git a/test/unit/selfhost-orb-collector.test.ts b/test/unit/selfhost-orb-collector.test.ts index 776b1fa5c8..35d9d73305 100644 --- a/test/unit/selfhost-orb-collector.test.ts +++ b/test/unit/selfhost-orb-collector.test.ts @@ -57,7 +57,7 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa beforeEach(() => { resetMetrics(); process.env.ORB_ENABLED = "true"; - process.env.ORB_WEBHOOK_SECRET = "test-secret"; + process.env.ORB_WEBHOOK_SECRET = "test-secret-at-least-32-bytes-long"; process.env.ORB_APP_ID = "555"; process.env.ORB_ANONYMIZE = "true"; delete process.env.ORB_AIR_GAP; @@ -164,9 +164,32 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/); }); - it("falls back to GITHUB_APP_ID for the instance id and applies secret/anonymize defaults when ORB_* are unset", async () => { + it("fails closed when anonymized export has no strong per-instance secret", async () => { + delete process.env.ORB_WEBHOOK_SECRET; + delete process.env.ORB_ANONYMIZE; // → defaults to "true" + const db = makeDb(); + await audit(db, "owner/repo", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z"); + await audit(db, "owner/repo", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z"); + let called = false; + const n = await exportOrbBatch(db, 200, async () => { called = true; return new Response(null, { status: 200 }); }); + expect(n).toBe(0); + expect(called).toBe(false); + expect(await renderMetrics()).toContain(`gittensory_orb_export_errors_total{reason="missing_anonymization_secret"} 1`); + }); + + it("fails closed when anonymized export has a weak per-instance secret", async () => { + process.env.ORB_WEBHOOK_SECRET = "short-secret"; + const db = makeDb(); + await audit(db, "owner/repo", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z"); + await audit(db, "owner/repo", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z"); + let called = false; + const n = await exportOrbBatch(db, 200, async () => { called = true; return new Response(null, { status: 200 }); }); + expect(n).toBe(0); + expect(called).toBe(false); + }); + + it("falls back to GITHUB_APP_ID for the instance id while using a configured anonymization secret", async () => { delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID - delete process.env.ORB_WEBHOOK_SECRET; // → secret defaults to "" delete process.env.ORB_ANONYMIZE; // → defaults to "true" (process.env as NodeJS.Dict).GITHUB_APP_ID = "999"; const db = makeDb();