Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/gittensory-engine/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,8 @@ export {
SECRET_PATTERNS,
redactSecrets,
} from "./subprocess-env.js";

// Shared telemetry-anonymization primitive (#5680) — one source of truth for the per-instance-secret HMAC
// hashing both Orb's self-host collector and AMS's export path use before repo/PR identifiers leave the
// instance.
export { generateAnonSecret, hmacAnonymize } from "./telemetry/anonymize.js";
29 changes: 29 additions & 0 deletions packages/gittensory-engine/src/telemetry/anonymize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// Shared telemetry-anonymization primitive (#5680): one source of truth for the per-instance-secret HMAC
// hashing every self-hosted product (Orb's `src/selfhost/orb-collector.ts`, and AMS's own export path) uses
// to anonymize repo/PR identifiers before they leave the instance. Extracted out of Orb-only code so a second,
// independently-maintained implementation never drifts from this one -- a weaker hash or a reused secret in
// only one of the two would be a real privacy bug, not style debt.
//
// Deliberately narrow: secret PERSISTENCE (where/how it's stored -- D1 for Orb, local SQLite for AMS) stays in
// each product's own store, since that's genuinely different per product. Only the pure hash/generate math
// lives here.
import { createHmac, randomBytes } from "node:crypto";

/**
* Generate a fresh, single-purpose 256-bit anonymization secret (64 hex chars). Each product persists this
* once per instance in its own store and reuses it on every export, so the same raw value always hashes the
* same way. Never derived from, or shared with, any other credential (App private keys, webhook secrets) --
* key separation means a leaked anonymization secret can't be used to forge or decrypt anything else.
*/
export function generateAnonSecret(): string {
return randomBytes(32).toString("hex");
}

/**
* HMAC-SHA256 `value` with the instance's own anonymization secret, truncated to 24 hex chars. The collector
* receiving the output never holds `secret`, so it can never reverse the hash back to the original value --
* it can only tell that two exports carrying the same hash referred to the same underlying repo/PR.
*/
export function hmacAnonymize(value: string, secret: string): string {
return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24);
}
40 changes: 40 additions & 0 deletions packages/gittensory-engine/test/anonymize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { test } from "node:test";
import assert from "node:assert/strict";

import { generateAnonSecret, hmacAnonymize } from "../dist/index.js";

test("generateAnonSecret: returns a 64-char hex string (256-bit secret)", () => {
const secret = generateAnonSecret();
assert.equal(secret.length, 64);
assert.match(secret, /^[0-9a-f]{64}$/);
});

test("generateAnonSecret: two calls never collide", () => {
assert.notEqual(generateAnonSecret(), generateAnonSecret());
});

test("hmacAnonymize: deterministic — same value+secret always hashes the same", () => {
const secret = "fixed-secret-for-test";
assert.equal(hmacAnonymize("acme/widgets", secret), hmacAnonymize("acme/widgets", secret));
});

test("hmacAnonymize: different values under the same secret hash differently", () => {
const secret = "fixed-secret-for-test";
assert.notEqual(hmacAnonymize("acme/widgets", secret), hmacAnonymize("acme/other", secret));
});

test("hmacAnonymize: the same value under different secrets hashes differently", () => {
assert.notEqual(hmacAnonymize("acme/widgets", "secret-a"), hmacAnonymize("acme/widgets", "secret-b"));
});

test("hmacAnonymize: output is truncated to 24 hex chars", () => {
const hash = hmacAnonymize("acme/widgets#42", "fixed-secret-for-test");
assert.equal(hash.length, 24);
assert.match(hash, /^[0-9a-f]{24}$/);
});

test("hmacAnonymize: matches Orb's own pre-extraction output for a known vector (regression)", () => {
// Fixed vector captured from the original inline `hmacField` in orb-collector.ts before extraction —
// guards against the refactor silently changing Orb's live anonymized output.
assert.equal(hmacAnonymize("acme/widgets", "known-fixed-secret"), "7323d8850fac6d7c2c4bdfae");
});
14 changes: 5 additions & 9 deletions src/selfhost/orb-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
// No diffs, no code, no comments, no logins, no commit SHAs — only verdict + outcome + reversal + a bucketed
// reason category + cycle time, with repo/PR identifiers HMAC'd by a key the collector never holds (so it
// can never de-anonymize).
import { createHash, createHmac, randomBytes } from "node:crypto";
import { createHash, createHmac } from "node:crypto";
import { generateAnonSecret, hmacAnonymize } from "../../packages/gittensory-engine/src/telemetry/anonymize.js";
import { incr } from "./metrics";

/** Key under which the per-instance anonymization secret is persisted in system_flags. */
Expand Down Expand Up @@ -60,11 +61,6 @@ function instanceId(anonSecret: string): string {
return createHash("sha256").update(seed).digest("hex").slice(0, 16);
}

/** HMAC a string with the instance's own secret for anonymized export. */
function hmacField(value: string, secret: string): string {
return createHmac("sha256", secret).update(value).digest("hex").slice(0, 24);
}

/**
* The instance's DEDICATED anonymization secret: a 256-bit random key generated once and persisted in
* system_flags, then reused on every export. Stable across restarts so a repo/PR always hashes the same
Expand All @@ -81,7 +77,7 @@ export async function getOrCreateAnonSecret(db: D1Database): Promise<string> {
};
const existing = await read();
if (existing) return existing;
const generated = randomBytes(32).toString("hex"); // 256-bit, 64 hex chars
const generated = generateAnonSecret(); // 256-bit, 64 hex chars
// Race-safe across instances sharing a Postgres DB: OR IGNORE keeps the first writer's key; the re-read
// returns whichever value won, so every instance converges on the same secret.
await db
Expand Down Expand Up @@ -188,8 +184,8 @@ export async function exportOrbBatch(db: D1Database, batchSize = 200, fetchFn: t
const payload: OrbExportPayload = {
instance_id: instance,
events: results.map((r) => ({
repo_hash: anonymize ? hmacField(r.project, secret) : r.project,
pr_hash: anonymize ? hmacField(r.target_id, secret) : r.target_id,
repo_hash: anonymize ? hmacAnonymize(r.project, secret) : r.project,
pr_hash: anonymize ? hmacAnonymize(r.target_id, secret) : r.target_id,
gate_verdict: r.verdict,
outcome: r.outcome,
reversal_flag: r.reverted ? "reverted" : r.reopened ? "reopened" : "none",
Expand Down
29 changes: 29 additions & 0 deletions test/unit/engine-telemetry-anonymize.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
// App-vitest coverage for the engine telemetry-anonymization primitive (#5680). codecov/patch is computed from
// this app vitest run (vitest.config coverage includes packages/gittensory-engine/src/**), so the changed engine
// lines need a vitest test that imports the SRC directly, in addition to the engine's own node:test suite.
import { describe, expect, it } from "vitest";
import { generateAnonSecret, hmacAnonymize } from "../../packages/gittensory-engine/src/telemetry/anonymize";

describe("engine telemetry-anonymize primitive (#5680)", () => {
it("generateAnonSecret returns a 64-char hex string and never collides across calls", () => {
const a = generateAnonSecret();
const b = generateAnonSecret();
expect(a).toMatch(/^[0-9a-f]{64}$/);
expect(b).toMatch(/^[0-9a-f]{64}$/);
expect(a).not.toBe(b);
});

it("hmacAnonymize is deterministic per value+secret, differs across values and across secrets, truncated to 24 hex chars", () => {
const secret = "fixed-secret-for-test";
expect(hmacAnonymize("acme/widgets", secret)).toBe(hmacAnonymize("acme/widgets", secret));
expect(hmacAnonymize("acme/widgets", secret)).not.toBe(hmacAnonymize("acme/other", secret));
expect(hmacAnonymize("acme/widgets", "secret-a")).not.toBe(hmacAnonymize("acme/widgets", "secret-b"));
expect(hmacAnonymize("acme/widgets#42", secret)).toMatch(/^[0-9a-f]{24}$/);
});

it("matches Orb's own pre-extraction output for a known vector (regression)", () => {
// Fixed vector captured from the original inline `hmacField` in orb-collector.ts before extraction —
// guards against the refactor silently changing Orb's live anonymized output.
expect(hmacAnonymize("acme/widgets", "known-fixed-secret")).toBe("7323d8850fac6d7c2c4bdfae");
});
});
Loading