From 4262994b4c248e0206fb6e24f8fffcb21a0facd1 Mon Sep 17 00:00:00 2001 From: "Ibrahim Bamidele. S" <238790935+bamiebot-maker@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:22:03 +0100 Subject: [PATCH] fix(audit,telemetry): add deep redaction, cyclic payload handling, and bounded traversal --- core/audit/__tests__/audit.test.ts | 29 +++++ core/audit/audit.ts | 11 +- core/telemetry/__tests__/telemetry.test.ts | 100 ++++++++++++++++ core/telemetry/telemetry.ts | 110 +++++++++++++++++- .../testing/__tests__/networkProfiles.test.ts | 23 +--- core/testing/networkProfileHandlers.ts | 24 ++-- 6 files changed, 260 insertions(+), 37 deletions(-) diff --git a/core/audit/__tests__/audit.test.ts b/core/audit/__tests__/audit.test.ts index 0be02e7..48da77e 100644 --- a/core/audit/__tests__/audit.test.ts +++ b/core/audit/__tests__/audit.test.ts @@ -114,6 +114,35 @@ describe("audit trail", () => { expect(auditContext({ only: { nested: 1 } })).toBeUndefined(); }); + it("redacts key-case variations and embedded bearer tokens in audit context", () => { + const context = auditContext({ + API_KEY: "my-key-value", + bearerToken: "custom-token", + PASSWORD: "my-password", + normal: "hello Bearer secret-auth-token-12345 world" + })!; + + expect(context.API_KEY).toBe("[REDACTED]"); + expect(context.bearerToken).toBe("[REDACTED]"); + expect(context.PASSWORD).toBe("[REDACTED]"); + expect(context.normal).toBe("hello [REDACTED] world"); + }); + + it("handles circular and complex objects in audit input without crashing", () => { + const cyclicObj: Record = { status: "pending" }; + cyclicObj.loop = cyclicObj; + + const event = record({ + before: { status: "initial", cyclic: cyclicObj as unknown as string } + }); + + expect(event).toBeDefined(); + expect(event?.outcome).toBe("allowed"); + const all = getAuditTrail().all(); + expect(all).toHaveLength(1); + expect(() => JSON.stringify(all)).not.toThrow(); + }); + it("is append-only: recording never rewrites an earlier event", () => { const first = record({ target: { kind: "worker_job", id: "job-1" } })!; const second = record({ target: { kind: "worker_job", id: "job-2" }, reason: "manual_retry" })!; diff --git a/core/audit/audit.ts b/core/audit/audit.ts index 7e48c6e..e8e504b 100644 --- a/core/audit/audit.ts +++ b/core/audit/audit.ts @@ -116,6 +116,8 @@ const AUDIT_MAX_VALUE_LENGTH = 160; /** A Stellar secret (seed) embedded in a longer string. */ const EMBEDDED_SECRET = /\b[SM][A-Z2-7]{55}\b/g; +const EMBEDDED_BEARER = /\bBearer\s+[A-Za-z0-9._~+/-]+\b/gi; +const SENSITIVE_KEY_PATTERN = /secret|seed|key|token|password|passphrase|auth|credential|bearer|cookie/i; const REASON_PATTERN = /^[a-z0-9_.:-]{1,64}$/; @@ -139,7 +141,9 @@ export interface AuditInput { } function scrubSecrets(value: string): string { - return value.replace(EMBEDDED_SECRET, "[REDACTED]"); + return value + .replace(EMBEDDED_SECRET, "[REDACTED]") + .replace(EMBEDDED_BEARER, "[REDACTED]"); } /** @@ -153,6 +157,11 @@ export function auditContext(value: Record | undefined): AuditC let keys = 0; for (const [key, entry] of Object.entries(value)) { if (keys >= AUDIT_MAX_CONTEXT_KEYS) break; + if (SENSITIVE_KEY_PATTERN.test(key)) { + out[key] = "[REDACTED]"; + keys += 1; + continue; + } if (entry === undefined || entry === null) { out[key] = null; keys += 1; diff --git a/core/telemetry/__tests__/telemetry.test.ts b/core/telemetry/__tests__/telemetry.test.ts index 6803294..4c04950 100644 --- a/core/telemetry/__tests__/telemetry.test.ts +++ b/core/telemetry/__tests__/telemetry.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { err, ok } from "@/core/result/result"; import { TELEMETRY_FIELDS, + createCaptureSink, measure, measureSync, newCorrelationId, @@ -100,6 +101,105 @@ describe("redaction", () => { const out = redact({ passphrase: "x", ok: true, nested: ["a", "SBXKEY212121"] }); expect(out).toEqual({ passphrase: "[REDACTED]", ok: true, nested: ["a", "[REDACTED]"] }); }); + + it("handles circular objects and arrays without crashing and remains serializable", () => { + const cyclicObj: Record = { name: "operation", details: { step: 1 } }; + cyclicObj.self = cyclicObj; + (cyclicObj.details as Record).parent = cyclicObj; + + const outObj = redact(cyclicObj) as Record; + expect(outObj.self).toBe("[CIRCULAR]"); + expect((outObj.details as Record).parent).toBe("[CIRCULAR]"); + expect(() => JSON.stringify(outObj)).not.toThrow(); + + const cyclicArr: unknown[] = ["first"]; + cyclicArr.push(cyclicArr); + const outArr = redact(cyclicArr) as unknown[]; + expect(outArr[1]).toBe("[CIRCULAR]"); + expect(() => JSON.stringify(outArr)).not.toThrow(); + }); + + it("handles deeply nested structures with bounded traversal depth", () => { + let deep: Record = { leaf: "SAKJFPVKPHAWLBQNFI3HK4DXMTPBSVJ6VNK4AXHYJNPEWTTZOFWLZWNW" }; + for (let i = 0; i < 20; i++) { + deep = { next: deep }; + } + const out = redact(deep); + expect(() => JSON.stringify(out)).not.toThrow(); + // At deep levels beyond MAX_REDACT_DEPTH (16), truncated marker is placed + const jsonStr = JSON.stringify(out); + expect(jsonStr).toContain("[TRUNCATED_DEPTH]"); + }); + + it("redacts key-case variations and auth variants", () => { + const out = redact({ + APIKEY: "val1", + secret_seed: "val2", + AUTH_TOKEN: "val3", + passWord: "val4", + Authorization: "Bearer xyz", + cookie: "session=123", + normalKey: "allowed" + }) as Record; + + expect(out.APIKEY).toBe("[REDACTED]"); + expect(out.secret_seed).toBe("[REDACTED]"); + expect(out.AUTH_TOKEN).toBe("[REDACTED]"); + expect(out.passWord).toBe("[REDACTED]"); + expect(out.Authorization).toBe("[REDACTED]"); + expect(out.cookie).toBe("[REDACTED]"); + expect(out.normalKey).toBe("allowed"); + }); + + it("scrubs embedded secrets and bearer tokens in preformatted strings and large strings", () => { + const seed = "SAKJFPVKPHAWLBQNFI3HK4DXMTPBSVJ6VNK4AXHYJNPEWTTZOFWLZWNW"; + const text = `Error connecting to Horizon with seed ${seed} and header Bearer eyJhbGciOiJIUzI1NiJ9`; + const out = redact(text); + expect(out).toBe("Error connecting to Horizon with seed [REDACTED] and header [REDACTED]"); + + // Large string test + const padding = "A".repeat(50_000); + const largeText = `${padding} ${seed} ${padding}`; + const largeOut = redact(largeText) as string; + expect(largeOut.includes(seed)).toBe(false); + expect(largeOut.includes("[REDACTED]")).toBe(true); + }); +}); + +describe("capture sink", () => { + it("records, finds, filters, and clears events correctly", () => { + const sink = createCaptureSink(); + expect(sink.events).toHaveLength(0); + expect(sink.latest()).toBeUndefined(); + + sink.emit({ + op: "horizon.request", + actorType: "client", + result: "success", + latencyMs: 12, + correlationId: "c1", + timestamp: "2026-09-27T00:00:00.000Z" + }); + + sink.emit({ + op: "export.generate", + actorType: "user", + result: "failure", + errorCode: "timeout", + latencyMs: 150, + correlationId: "c2", + timestamp: "2026-09-27T00:00:01.000Z" + }); + + expect(sink.events).toHaveLength(2); + expect(sink.latest()?.op).toBe("export.generate"); + expect(sink.find((e) => e.op === "horizon.request")?.correlationId).toBe("c1"); + expect(sink.filter((e) => e.result === "failure")).toHaveLength(1); + + sink.clear(); + expect(sink.events).toHaveLength(0); + expect(sink.latest()).toBeUndefined(); + }); }); describe("correlation ids", () => { diff --git a/core/telemetry/telemetry.ts b/core/telemetry/telemetry.ts index b24a172..a87a4ad 100644 --- a/core/telemetry/telemetry.ts +++ b/core/telemetry/telemetry.ts @@ -44,34 +44,134 @@ export interface TelemetrySink { const STELLAR_SECRET_PREFIXES = ["S", "M"] as const; +export const MAX_REDACT_DEPTH = 16; +export const MAX_SCANNABLE_STRING_LENGTH = 500_000; + +/** Embedded Stellar secret seed (56 characters starting with S or M). */ +const EMBEDDED_STELLAR_SECRET = /\b[SM][A-Z2-7]{55}\b/g; + +/** Embedded bearer tokens / authorization headers. */ +const EMBEDDED_BEARER_TOKEN = /\bBearer\s+[A-Za-z0-9._~+/-]+\b/gi; + +function isSensitiveKey(key: string): boolean { + if (/public/i.test(key)) return false; + return /secret|seed|password|passphrase|token|auth|authorization|credential|bearer|cookie|(?:^|[_\b]|private|api|secret|signing)key/i.test(key); +} + function looksLikeSecret(value: string): boolean { if (value.length < 10 || value.length > 128) return false; return STELLAR_SECRET_PREFIXES.some((prefix) => value.startsWith(prefix)); } +/** + * Scans and scrubs secret seeds, bearer tokens, or full secrets in strings. + */ +function scrubString(value: string): string { + if (looksLikeSecret(value)) return "[REDACTED]"; + const candidate = + value.length > MAX_SCANNABLE_STRING_LENGTH + ? value.slice(0, MAX_SCANNABLE_STRING_LENGTH) + "…[TRUNCATED]" + : value; + return candidate + .replace(/\b[SM][A-Z2-7]{55}\b/g, "[REDACTED]") + .replace(/\bBearer\s+[A-Za-z0-9._~+/-]+\b/gi, "[REDACTED]"); +} + /** * Recursively replaces values that look like secret material so no sink ever * sees a seed key, passphrase or bearer token. Keys are always preserved so * the shape of a record stays stable and queryable. + * + * Implements bounded traversal depth (MAX_REDACT_DEPTH) and cycle detection + * to guarantee that circular objects never cause infinite recursion and the + * result is safely serializable. */ -export function redact(value: unknown): unknown { +export function redact( + value: unknown, + depth = 0, + seen: WeakSet = new WeakSet() +): unknown { if (typeof value === "string") { - return looksLikeSecret(value) ? "[REDACTED]" : value; + return scrubString(value); } if (value === null || value === undefined) return value; - if (Array.isArray(value)) return value.map(redact); + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return value; + } + if (typeof value === "function" || typeof value === "symbol") { + return undefined; + } + + if (depth >= MAX_REDACT_DEPTH) { + return "[TRUNCATED_DEPTH]"; + } + if (typeof value === "object") { + if (seen.has(value)) { + return "[CIRCULAR]"; + } + seen.add(value); + + if (Array.isArray(value)) { + return value.map((entry) => redact(entry, depth + 1, seen)); + } + + if (value instanceof Error) { + const out: Record = { + name: value.name, + message: scrubString(value.message) + }; + if (value.stack) { + out.stack = scrubString(value.stack); + } + return out; + } + const out: Record = {}; for (const [key, entry] of Object.entries(value)) { - out[key] = /secret|seed|key|token|password|passphrase/i.test(key) + out[key] = isSensitiveKey(key) ? "[REDACTED]" - : redact(entry); + : redact(entry, depth + 1, seen); } return out; } + return value; } +export interface TelemetryCaptureSink extends TelemetrySink { + readonly events: readonly TelemetryEvent[]; + clear(): void; + find(predicate: (event: TelemetryEvent) => boolean): TelemetryEvent | undefined; + filter(predicate: (event: TelemetryEvent) => boolean): TelemetryEvent[]; + latest(): TelemetryEvent | undefined; +} + +/** In-memory capture sink for testing assertions against emitted telemetry events. */ +export function createCaptureSink(): TelemetryCaptureSink { + const captured: TelemetryEvent[] = []; + return { + get events() { + return [...captured]; + }, + emit(event: TelemetryEvent): void { + captured.push(event); + }, + clear(): void { + captured.length = 0; + }, + find(predicate: (event: TelemetryEvent) => boolean): TelemetryEvent | undefined { + return captured.find(predicate); + }, + filter(predicate: (event: TelemetryEvent) => boolean): TelemetryEvent[] { + return captured.filter(predicate); + }, + latest(): TelemetryEvent | undefined { + return captured[captured.length - 1]; + } + }; +} + /** A cheap unique correlation id shared across the requests a single action fans out into. */ export function newCorrelationId(): string { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { diff --git a/core/testing/__tests__/networkProfiles.test.ts b/core/testing/__tests__/networkProfiles.test.ts index 02b0c71..bbe297c 100644 --- a/core/testing/__tests__/networkProfiles.test.ts +++ b/core/testing/__tests__/networkProfiles.test.ts @@ -164,54 +164,37 @@ describe("network profile MSW handlers", () => { }); describe("network profile filtering", () => { - it("filters by feature networks", () => { - const contexts: NetworkProfileContext[] = []; - + describe("filters by feature networks", () => { runAgainstNetworkProfiles( { featureNetworks: ["testnet"] }, (ctx) => { - contexts.push(ctx); it(`runs for ${ctx.profile.id}`, () => { expect(ctx.profile.network).toBe("testnet"); }); } ); - - // The helper creates describe blocks; we can't easily inspect them - // synchronously, but we can verify the function doesn't throw - expect(contexts).toBeDefined(); }); - it("includes futurenet only when requested", () => { - const contexts: NetworkProfileContext[] = []; - + describe("includes futurenet only when requested", () => { runAgainstNetworkProfiles( { featureNetworks: ["testnet", "mainnet"], includeFuturenet: true }, (ctx) => { - contexts.push(ctx); it(`runs for ${ctx.profile.id}`, () => { expect(ctx.profile).toBeDefined(); }); } ); - - expect(contexts).toBeDefined(); }); - it("filters by specific profile IDs", () => { - const contexts: NetworkProfileContext[] = []; - + describe("filters by specific profile IDs", () => { runAgainstNetworkProfiles( { featureNetworks: ["testnet", "mainnet"], profileIds: ["testnet-fresh-account"] }, (ctx) => { - contexts.push(ctx); it(`runs for ${ctx.profile.id}`, () => { expect(ctx.profile.id).toBe("testnet-fresh-account"); }); } ); - - expect(contexts).toBeDefined(); }); }); diff --git a/core/testing/networkProfileHandlers.ts b/core/testing/networkProfileHandlers.ts index b90ebd0..37f8007 100644 --- a/core/testing/networkProfileHandlers.ts +++ b/core/testing/networkProfileHandlers.ts @@ -120,17 +120,19 @@ export function networkProfileHandlers(profileId: NetworkProfileId) { () => { return HttpResponse.json({ _links: { self: { href: "" }, next: { href: "" }, prev: { href: "" } }, - _embedded: { records: fixtures.accountResponse.balances - .filter((b: { asset_type?: string }) => b.asset_type !== "native") - .map((b: { asset_code?: string; asset_issuer?: string; asset_type?: string }) => ({ - asset_type: b.asset_type, - asset_code: b.asset_code, - asset_issuer: b.asset_issuer, - balance: "0.0000000", - limit: "922337203685.4775807", - })), - _records: [], - }, { headers: profileHeader }); + _embedded: { + records: fixtures.accountResponse.balances + .filter((b: { asset_type?: string }) => b.asset_type !== "native") + .map((b: { asset_code?: string; asset_issuer?: string; asset_type?: string }) => ({ + asset_type: b.asset_type, + asset_code: b.asset_code, + asset_issuer: b.asset_issuer, + balance: "0.0000000", + limit: "922337203685.4775807", + })), + }, + _records: [], + }, { headers: profileHeader }); } ),