Skip to content

Commit 365d780

Browse files
committed
fix(orb): derive a dedicated anonymization key + log export failures
Address two review findings on the always-on telemetry: P1 — stop reusing GITHUB_WEBHOOK_SECRET as the anonymization key. A low-entropy webhook secret made the 24-char HMACs brute-forceable, and coupling the two boundaries meant rotating the webhook secret would silently break repo-hash dedup continuity on the collector. Derive a DEDICATED, domain-separated key from GITHUB_APP_PRIVATE_KEY (high-entropy RSA material) via HMAC(appKey, "gittensory-orb-anon-v1"), and gate export on the App private key. No new operator config; the webhook secret stays single-purpose. P2 — the always-on hourly export cron no longer swallows errors silently; it logs a structured selfhost_orb_export_error (message) so operators can detect reliability issues.
1 parent 8b628fe commit 365d780

4 files changed

Lines changed: 22 additions & 15 deletions

File tree

.env.example

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,9 @@ GITTENSORY_REVIEW_DRAFT=false
170170
#
171171
# WHAT IS SENT (per resolved PR, hourly): the gate verdict, the realized outcome (merged/closed), a reversal
172172
# flag, a bucketed reason category, and cycle time. NEVER sent: repo/owner/PR names, commit SHAs, code,
173-
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with YOUR OWN GitHub App webhook
174-
# secret (GITHUB_WEBHOOK_SECRET), so even gittensory (running the collector) can never de-anonymize them.
173+
# diffs, comments, or logins. Repo/PR identifiers are HMAC-anonymized with a DEDICATED key derived from YOUR
174+
# OWN App private key (GITHUB_APP_PRIVATE_KEY) — high-entropy and independent of your webhook secret, so even
175+
# gittensory (running the collector) can never de-anonymize them.
175176
# The export carries no shared key; the collector treats it as untrusted, rate-limited, aggregate-only data.
176177
# ORB_AIR_GAP=false # air-gapped/OFFLINE deployments only: compute locally, never send
177178
# ORB_ANONYMIZE=true # HMAC-hash repo/PR before export (default true; false = raw names)

src/selfhost/orb-collector.ts

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@
44
// collector so the gate can be calibrated across the whole self-host fleet.
55
//
66
// Export is ALWAYS ON once the GitHub App is configured (the fleet-telemetry contract of self-hosting) —
7-
// there is no opt-out flag. It self-gates on a configured App webhook secret (no App → no review data to
8-
// export anyway), and uses that secret as the per-instance anonymization key.
7+
// there is no opt-out flag. It self-gates on a configured App private key (no App → no review data to
8+
// export anyway), from which it derives a DEDICATED, domain-separated anonymization key (so it never
9+
// reuses the webhook-verification secret and survives webhook-secret rotation).
910
// ORB_COLLECTOR_URL=<url> — endpoint (default: gittensory's hosted collector)
1011
// ORB_AIR_GAP=true — air-gapped/offline deployments only: compute locally, never send
1112
// ORB_ANONYMIZE=true — HMAC-hash repo/PR before export (default: true)
1213
//
1314
// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
14-
// reason category + cycle time, with repo/PR identifiers HMAC'd by THIS instance's own secret (the collector
15-
// holds no instance secret, so it can never de-anonymize).
15+
// reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it
16+
// can never de-anonymize).
1617
import { createHash, createHmac } from "node:crypto";
1718
import { incr } from "./metrics";
1819

@@ -125,11 +126,16 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
125126
// Always on (no opt-out). Air-gapped/offline deployments may suppress the outbound call.
126127
if ((process.env.ORB_AIR_GAP ?? "").toLowerCase() === "true") return 0;
127128

128-
// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with THIS
129-
// instance's OWN GitHub App webhook secret, so the collector can never de-anonymize them.
129+
// No App configured → no review data to export anyway. The App's PRIVATE KEY (high-entropy RSA material)
130+
// gates export and seeds anonymization; the webhook-verification secret is never reused here.
131+
const appKey = process.env.GITHUB_APP_PRIVATE_KEY ?? "";
132+
if (!appKey) return 0;
133+
134+
// gittensory's hosted collector. No shared secret is sent: repo/PR identifiers are HMAC'd with a DEDICATED,
135+
// domain-separated key derived from THIS instance's App private key — so it's high-entropy (not brute-forceable),
136+
// single-purpose (independent of webhook-secret rotation), and the collector can never de-anonymize them.
130137
const collectorUrl = process.env.ORB_COLLECTOR_URL ?? "https://gittensory-api.aethereal.dev/v1/orb/ingest";
131-
const secret = process.env.GITHUB_WEBHOOK_SECRET ?? "";
132-
if (!secret) return 0; // App not configured yet — no anonymization key, and no review data to export
138+
const secret = createHmac("sha256", appKey).update("gittensory-orb-anon-v1").digest("hex");
133139
const anonymize = (process.env.ORB_ANONYMIZE ?? "true").toLowerCase() !== "false";
134140
const instance = instanceId();
135141

src/server.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ async function main(): Promise<void> {
290290
const runOrbExport = () =>
291291
exportOrbBatch(backend.db)
292292
.then((n) => { if (n > 0) console.log(JSON.stringify({ event: "selfhost_orb_export", exported: n })); })
293-
.catch(() => undefined);
293+
.catch((error) => console.error(JSON.stringify({ level: "error", event: "selfhost_orb_export_error", error: error instanceof Error ? error.message : "unknown error" })));
294294
void runOrbExport(); // flush any pending events at startup
295295
setInterval(runOrbExport, 3_600_000); // then hourly
296296

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

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,18 @@ describe("bucketReasonCode()", () => {
4747
describe("exportOrbBatch() — always-on; reads review_audit, ships anonymized reversal-aware signal", () => {
4848
beforeEach(() => {
4949
resetMetrics();
50-
process.env.GITHUB_WEBHOOK_SECRET = "test-secret"; // the App secret doubles as the anonymization key
50+
(process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY = "test-private-key"; // seeds the derived anonymization key
5151
process.env.ORB_APP_ID = "555";
5252
process.env.ORB_ANONYMIZE = "true";
5353
delete process.env.ORB_AIR_GAP;
5454
delete process.env.ORB_COLLECTOR_URL;
5555
});
5656
afterEach(() => {
57-
for (const k of ["GITHUB_WEBHOOK_SECRET", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
57+
for (const k of ["GITHUB_APP_PRIVATE_KEY", "ORB_APP_ID", "ORB_ANONYMIZE", "ORB_AIR_GAP", "ORB_COLLECTOR_URL", "GITHUB_APP_ID"]) delete (process.env as NodeJS.Dict<string>)[k];
5858
});
5959

60-
it("returns 0 when the App secret is not configured (App not set up → nothing to export)", async () => {
61-
delete (process.env as NodeJS.Dict<string>).GITHUB_WEBHOOK_SECRET;
60+
it("returns 0 when the App private key is not configured (App not set up → nothing to export)", async () => {
61+
delete (process.env as NodeJS.Dict<string>).GITHUB_APP_PRIVATE_KEY;
6262
const db = makeDb();
6363
await audit(db, "o/r", 1, "gate_decision", "merge", "2026-01-01T00:00:00Z");
6464
await audit(db, "o/r", 1, "pr_outcome", "merged", "2026-01-01T01:00:00Z");

0 commit comments

Comments
 (0)