Skip to content

Commit ac53908

Browse files
committed
fix(orb): require anonymization secret for export
1 parent a6e32e7 commit ac53908

3 files changed

Lines changed: 33 additions & 6 deletions

File tree

.env.example

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,16 +164,16 @@ GITTENSORY_REVIEW_DRAFT=false
164164
# --- Gittensory Orb (#1255; opt-in fleet-calibration export) ---
165165
# Orb is the central collector + analytics that aggregates anonymized gate-calibration data UP from
166166
# self-hosted instances. There is NO separate Orb GitHub App and NO setup wizard: your existing main App
167-
# already records de-noised outcomes (merged/closed + reversals) locally — flip ORB_ENABLED to ship an
168-
# anonymized signal to gittensory's collector. That's it: no second App, no extra secret, no wizard.
167+
# already records de-noised outcomes (merged/closed + reversals) locally — set ORB_ENABLED plus a
168+
# stable per-instance ORB_WEBHOOK_SECRET to ship an anonymized signal to gittensory's collector.
169169
#
170170
# SECURITY MODEL (this image is self-hosted by many independent maintainers):
171171
# • The image bakes NO secrets. repo/PR identifiers are HMAC-anonymized with YOUR own ORB_WEBHOOK_SECRET
172172
# (a stable per-instance string), so even gittensory (running the collector) can never de-anonymize them.
173173
# • Export carries NO shared key. The collector accepts the batch as untrusted, rate-limited, aggregate-only
174174
# telemetry. Nothing in the container, if leaked, can compromise the collector, other operators, or any App.
175175
# ORB_ENABLED=false # master switch: set to true to export fleet-calibration signal (default off)
176-
# ORB_WEBHOOK_SECRET=<stable-random-string> # the per-instance HMAC key used to anonymize repo/PR identifiers
176+
# ORB_WEBHOOK_SECRET=<32+ char stable random string> # required when ORB_ANONYMIZE=true; per-instance HMAC key
177177
# ORB_AIR_GAP=false # set to true to compute locally but never send to the collector
178178
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)
179179
# ORB_COLLECTOR_URL=https://gittensory-api.aethereal.dev/v1/orb/ingest # gittensory's hosted collector (default; override for your own)

src/selfhost/orb-collector.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,10 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
134134
const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest";
135135
const secret = process.env.ORB_WEBHOOK_SECRET ?? "";
136136
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";
137+
if (anonymize && secret.trim().length < 32) {
138+
incr("gittensory_orb_export_errors_total", { reason: "missing_anonymization_secret" });
139+
return 0;
140+
}
137141
const instance = instanceId();
138142

139143
// Read this instance's export watermark (resumes where the last run left off).

test/unit/selfhost-orb-collector.test.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa
5757
beforeEach(() => {
5858
resetMetrics();
5959
process.env.ORB_ENABLED = "true";
60-
process.env.ORB_WEBHOOK_SECRET = "test-secret";
60+
process.env.ORB_WEBHOOK_SECRET = "test-secret-at-least-32-bytes-long";
6161
process.env.ORB_APP_ID = "555";
6262
process.env.ORB_ANONYMIZE = "true";
6363
delete process.env.ORB_AIR_GAP;
@@ -164,9 +164,32 @@ describe("exportOrbBatch() — reads review_audit, ships anonymized reversal-awa
164164
expect(sig).toMatch(/^sha256=[a-f0-9]{64}$/);
165165
});
166166

167-
it("falls back to GITHUB_APP_ID for the instance id and applies secret/anonymize defaults when ORB_* are unset", async () => {
167+
it("fails closed when anonymized export has no strong per-instance secret", async () => {
168+
delete process.env.ORB_WEBHOOK_SECRET;
169+
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
170+
const db = makeDb();
171+
await audit(db, "owner/repo", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
172+
await audit(db, "owner/repo", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
173+
let called = false;
174+
const n = await exportOrbBatch(db, 200, async () => { called = true; return new Response(null, { status: 200 }); });
175+
expect(n).toBe(0);
176+
expect(called).toBe(false);
177+
expect(await renderMetrics()).toContain(`gittensory_orb_export_errors_total{reason="missing_anonymization_secret"} 1`);
178+
});
179+
180+
it("fails closed when anonymized export has a weak per-instance secret", async () => {
181+
process.env.ORB_WEBHOOK_SECRET = "short-secret";
182+
const db = makeDb();
183+
await audit(db, "owner/repo", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
184+
await audit(db, "owner/repo", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");
185+
let called = false;
186+
const n = await exportOrbBatch(db, 200, async () => { called = true; return new Response(null, { status: 200 }); });
187+
expect(n).toBe(0);
188+
expect(called).toBe(false);
189+
});
190+
191+
it("falls back to GITHUB_APP_ID for the instance id while using a configured anonymization secret", async () => {
168192
delete process.env.ORB_APP_ID; // → falls through to GITHUB_APP_ID
169-
delete process.env.ORB_WEBHOOK_SECRET; // → secret defaults to ""
170193
delete process.env.ORB_ANONYMIZE; // → defaults to "true"
171194
(process.env as NodeJS.Dict<string>).GITHUB_APP_ID = "999";
172195
const db = makeDb();

0 commit comments

Comments
 (0)