From 6f5e6dad199834a6b40f24b5b05c8516f2fd995d 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 1/2] 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 }); } ), From 96d2e21fc50c9b8d35388d8410c39d7b88944912 Mon Sep 17 00:00:00 2001 From: "Ibrahim Bamidele. S" <238790935+bamiebot-maker@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:43:27 +0100 Subject: [PATCH 2/2] test(telemetry): verify telemetry redaction at feature boundaries and integrate capture sink in network profiles --- .../__tests__/featureBoundaries.test.ts | 267 ++++++++++++++++++ core/telemetry/telemetry.ts | 32 ++- core/testing/networkProfiles.ts | 6 + core/testing/runAgainstProfiles.ts | 19 +- 4 files changed, 313 insertions(+), 11 deletions(-) create mode 100644 core/telemetry/__tests__/featureBoundaries.test.ts diff --git a/core/telemetry/__tests__/featureBoundaries.test.ts b/core/telemetry/__tests__/featureBoundaries.test.ts new file mode 100644 index 0000000..da13fa3 --- /dev/null +++ b/core/telemetry/__tests__/featureBoundaries.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { runHorizonRequest } from "@/core/horizon/request"; +import { exportRecords, type ExportRequest } from "@/core/export/exporter"; +import { + TELEMETRY_FIELDS, + createCaptureSink, + emitTelemetry, + measure, + measureSync, + newCorrelationId, + redact, + resetTelemetrySink, + setTelemetrySink, + type TelemetryCaptureSink, + type TelemetryEvent +} from "@/core/telemetry/telemetry"; +import { runAgainstNetworkProfiles } from "@/core/testing/runAgainstProfiles"; + +describe("feature boundary telemetry redaction", () => { + let sink: TelemetryCaptureSink; + + beforeEach(() => { + sink = createCaptureSink(); + setTelemetrySink(sink); + }); + + afterEach(() => { + resetTelemetrySink(); + }); + + describe("correlation metadata and allowed fields", () => { + it("preserves required correlation fields and metadata on feature failure", () => { + const correlationId = newCorrelationId(); + + emitTelemetry({ + op: "horizon.request", + actorType: "client", + result: "failure", + latencyMs: 42, + correlationId, + errorCode: "timeout", + payload: { + endpoint: "https://horizon-testnet.stellar.org/accounts", + attempt: 2 + } + }); + + expect(sink.events).toHaveLength(1); + const event = sink.latest()!; + + // Allowed correlation metadata + expect(event.op).toBe("horizon.request"); + expect(event.actorType).toBe("client"); + expect(event.result).toBe("failure"); + expect(event.latencyMs).toBe(42); + expect(event.correlationId).toBe(correlationId); + expect(event.errorCode).toBe("timeout"); + expect(event.timestamp).toBeDefined(); + expect(new Date(event.timestamp).getTime()).not.toBeNaN(); + expect(event.payload?.endpoint).toBe("https://horizon-testnet.stellar.org/accounts"); + expect(event.payload?.attempt).toBe(2); + + // Verify all required contract keys are present + for (const required of TELEMETRY_FIELDS) { + expect(event).toHaveProperty(required); + } + }); + }); + + describe("preformatted sensitive strings and raw HTTP bodies at caller boundary", () => { + const secretSeed = "SAKJFPVKPHAWLBQNFI3HK4DXMTPBSVJ6VNK4AXHYJNPEWTTZOFWLZWNW"; + const bearerToken = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"; + const rawHttpBody = JSON.stringify({ + type: "bad_request", + title: "Transaction Failed", + status: 400, + detail: `Seed ${secretSeed} is invalid for this envelope`, + extras: { envelope_xdr: "AAAAAGX..." } + }); + + it("scrubs embedded secrets when callers preformat error strings", () => { + const preformattedError = `Horizon failed for seed ${secretSeed} using ${bearerToken}`; + + emitTelemetry({ + op: "horizon.request", + actorType: "client", + result: "failure", + errorCode: `err_for_${secretSeed}`, + payload: { + message: preformattedError, + rawBody: rawHttpBody, + responseBody: rawHttpBody, + body: rawHttpBody + } + }); + + const event = sink.latest()!; + expect(event.result).toBe("failure"); + + // errorCode must have scrubbed secret + expect(event.errorCode).not.toContain(secretSeed); + expect(event.errorCode).toContain("[REDACTED]"); + + // payload message must have scrubbed seed and bearer token + const payload = event.payload as Record; + expect(payload.message).not.toContain(secretSeed); + expect(payload.message).not.toContain(bearerToken); + expect(payload.message).toBe("Horizon failed for seed [REDACTED] using [REDACTED]"); + + // raw HTTP body keys must be redacted + expect(payload.rawBody).toBe("[REDACTED]"); + expect(payload.responseBody).toBe("[REDACTED]"); + expect(payload.body).toBe("[REDACTED]"); + + // Verify complete serialization is clean of secret material + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(secretSeed); + expect(serialized).not.toContain("eyJhbGciOiJIUzI1NiI"); + expect(serialized).not.toContain("AAAAAGX"); + }); + + it("redacts raw HTTP body and disallowed address keys in feature payloads", () => { + const publicAddress = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + + emitTelemetry({ + op: "export.generate", + actorType: "user", + result: "failure", + errorCode: "export_denied", + payload: { + allowedAccountId: publicAddress, + disallowedAddress: publicAddress, + forbiddenAddress: publicAddress, + raw_body: "RAW_STREAM_DATA", + httpBody: "HTTP_PAYLOAD" + } + }); + + const event = sink.latest()!; + const payload = event.payload as Record; + + // Allowed public account is preserved in non-sensitive context + expect(payload.allowedAccountId).toBe(publicAddress); + + // Disallowed address keys are redacted + expect(payload.disallowedAddress).toBe("[REDACTED]"); + expect(payload.forbiddenAddress).toBe("[REDACTED]"); + + // Raw HTTP body fields are redacted + expect(payload.raw_body).toBe("[REDACTED]"); + expect(payload.httpBody).toBe("[REDACTED]"); + }); + + it("supports address redaction when explicitly requested", () => { + const publicAddress = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5"; + const unredacted = { + destination: publicAddress, + info: `Payment sent to ${publicAddress}` + }; + + const out = redact(unredacted, 0, new WeakSet(), { redactAddresses: true }) as Record; + expect(out.destination).toBe("[REDACTED]"); + expect(out.info).toBe("Payment sent to [REDACTED]"); + expect(JSON.stringify(out)).not.toContain(publicAddress); + }); + }); + + describe("actual feature request and error execution paths", () => { + it("captures correlation metadata on Horizon timeout without leaking raw errors", async () => { + const correlationId = newCorrelationId(); + const hangingRequest = new Promise((resolve) => setTimeout(resolve, 500)); + + await expect( + runHorizonRequest(hangingRequest, { + timeoutMs: 10, + correlationId + }) + ).rejects.toThrow("Horizon request timed out"); + + expect(sink.events).toHaveLength(1); + const event = sink.latest()!; + expect(event.op).toBe("horizon.request"); + expect(event.actorType).toBe("client"); + expect(event.result).toBe("failure"); + expect(event.errorCode).toBe("timeout"); + expect(event.correlationId).toBe(correlationId); + expect(event.latencyMs).toBeGreaterThanOrEqual(0); + }); + + it("captures correlation metadata on Horizon cancellation", async () => { + const controller = new AbortController(); + const correlationId = newCorrelationId(); + const hangingRequest = new Promise((resolve) => setTimeout(resolve, 500)); + + const promise = runHorizonRequest(hangingRequest, { + signal: controller.signal, + correlationId + }); + + controller.abort(); + + await expect(promise).rejects.toThrow("Horizon request cancelled"); + + expect(sink.events).toHaveLength(1); + const event = sink.latest()!; + expect(event.op).toBe("horizon.request"); + expect(event.result).toBe("failure"); + expect(event.errorCode).toBe("cancelled"); + expect(event.correlationId).toBe(correlationId); + }); + + it("captures correlation metadata in export workflow without leaking maintainer tokens", () => { + const secretToken = "SAKJFPVKPHAWLBQNFI3HK4DXMTPBSVJ6VNK4AXHYJNPEWTTZOFWLZWNW"; + const request: ExportRequest = { + actor: { kind: "user" }, + scope: "maintainer", // User requesting maintainer scope fails authorization + correlationId: newCorrelationId() + }; + + const result = exportRecords(request, []); + expect(result.ok).toBe(false); + + const event = sink.latest()!; + expect(event.op).toBe("export.generate"); + expect(event.actorType).toBe("user"); + expect(event.result).toBe("failure"); + expect(event.errorCode).toBe("export_denied"); + expect(event.correlationId).toBe(request.correlationId); + + const serialized = JSON.stringify(event); + expect(serialized).not.toContain(secretToken); + }); + }); +}); + +describe("runAgainstNetworkProfiles telemetry context integration", () => { + runAgainstNetworkProfiles( + { featureNetworks: ["testnet", "mainnet"] }, + (ctx) => { + it(`[${ctx.profile.id}] provides capture sink in profile context and records Horizon requests`, async () => { + expect(ctx.telemetry).toBeDefined(); + ctx.telemetry!.clear(); + + // Perform a simulated Horizon request using the profile's horizonUrl + const correlationId = newCorrelationId(); + const request = fetch(`${ctx.profile.horizonUrl}/accounts/${ctx.fixtures.accountId}`); + + const result = await runHorizonRequest(request, { correlationId }); + expect(result).toBeDefined(); + + const events = ctx.telemetry!.events; + expect(events.length).toBeGreaterThanOrEqual(1); + + const horizonEvent = ctx.telemetry!.find((e) => e.op === "horizon.request"); + expect(horizonEvent).toBeDefined(); + expect(horizonEvent!.actorType).toBe("client"); + expect(horizonEvent!.correlationId).toBe(correlationId); + expect(horizonEvent!.result).toBe("success"); + + // Verify no raw HTTP body or secrets in the telemetry record + const serialized = JSON.stringify(horizonEvent); + expect(serialized).not.toContain(ctx.fixtures.accountId); // The telemetry event doesn't log the raw account + expect(horizonEvent!.payload).toBeUndefined(); + }); + } + ); +}); diff --git a/core/telemetry/telemetry.ts b/core/telemetry/telemetry.ts index a87a4ad..92e2576 100644 --- a/core/telemetry/telemetry.ts +++ b/core/telemetry/telemetry.ts @@ -48,14 +48,21 @@ 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; +const EMBEDDED_STELLAR_SECRET = /[SM][A-Z2-7]{55}/g; /** Embedded bearer tokens / authorization headers. */ -const EMBEDDED_BEARER_TOKEN = /\bBearer\s+[A-Za-z0-9._~+/-]+\b/gi; +const EMBEDDED_BEARER_TOKEN = /Bearer\s+[A-Za-z0-9._~+/-]+/gi; + +export const STELLAR_PUBLIC_ADDRESS_REGEX = /[GC][A-Z2-7]{55}/g; + +export interface RedactOptions { + /** If true, redacts all Stellar public addresses (G... / C...) in values. */ + redactAddresses?: boolean; +} 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); + return /secret|seed|password|passphrase|token|auth|authorization|credential|bearer|cookie|raw_?body|response_?body|request_?body|http_?body|response_?data|^(?:body|raw)$|disallowed_?address|forbidden_?address|disallowed_?account|forbidden_?account|(?:^|[_\b]|private|api|secret|signing)key/i.test(key); } function looksLikeSecret(value: string): boolean { @@ -73,8 +80,8 @@ function scrubString(value: string): string { ? 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]"); + .replace(/[SM][A-Z2-7]{55}/g, "[REDACTED]") + .replace(/Bearer\s+[A-Za-z0-9._~+/-]+/gi, "[REDACTED]"); } /** @@ -89,10 +96,15 @@ function scrubString(value: string): string { export function redact( value: unknown, depth = 0, - seen: WeakSet = new WeakSet() + seen: WeakSet = new WeakSet(), + options?: RedactOptions ): unknown { if (typeof value === "string") { - return scrubString(value); + let scrubbed = scrubString(value); + if (options?.redactAddresses) { + scrubbed = scrubbed.replace(STELLAR_PUBLIC_ADDRESS_REGEX, "[REDACTED]"); + } + return scrubbed; } if (value === null || value === undefined) return value; if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { @@ -113,7 +125,7 @@ export function redact( seen.add(value); if (Array.isArray(value)) { - return value.map((entry) => redact(entry, depth + 1, seen)); + return value.map((entry) => redact(entry, depth + 1, seen, options)); } if (value instanceof Error) { @@ -131,7 +143,7 @@ export function redact( for (const [key, entry] of Object.entries(value)) { out[key] = isSensitiveKey(key) ? "[REDACTED]" - : redact(entry, depth + 1, seen); + : redact(entry, depth + 1, seen, options); } return out; } @@ -241,7 +253,7 @@ export function emitTelemetry(fields: EmitTelemetryFields): void { result: fields.result ?? "success", latencyMs: fields.latencyMs ?? 0, correlationId: fields.correlationId ?? newCorrelationId(), - errorCode: fields.errorCode, + errorCode: fields.errorCode ? scrubString(fields.errorCode) : undefined, // Redaction happens here, at the boundary, so every sink — including an // in-memory test sink — only ever sees safe values. payload: typeof fields.payload === "object" && fields.payload !== null diff --git a/core/testing/networkProfiles.ts b/core/testing/networkProfiles.ts index fc21c25..582b35a 100644 --- a/core/testing/networkProfiles.ts +++ b/core/testing/networkProfiles.ts @@ -117,6 +117,8 @@ export interface ProfileFixtures { }; } +import type { TelemetryCaptureSink } from "@/core/telemetry/telemetry"; + export interface NetworkProfileContext { /** The profile being tested. */ profile: NetworkProfile; @@ -124,6 +126,10 @@ export interface NetworkProfileContext { fixtures: ProfileFixtures; /** Pre-built MSW handlers for this profile. */ handlers: RequestHandler[]; + /** Server instance for MSW overrides. */ + server?: unknown; + /** In-memory capture sink for telemetry assertions during profile tests. */ + telemetry?: TelemetryCaptureSink; } // --------------------------------------------------------------------------- diff --git a/core/testing/runAgainstProfiles.ts b/core/testing/runAgainstProfiles.ts index 71bf4e2..4887bcd 100644 --- a/core/testing/runAgainstProfiles.ts +++ b/core/testing/runAgainstProfiles.ts @@ -33,7 +33,7 @@ * ``` */ -import { describe, it, type RequestHandler } from "vitest"; +import { afterEach, beforeEach, describe, it, type RequestHandler } from "vitest"; import { withMswHandlers } from "@/core/testing/msw"; import type { StellarNetwork } from "@/core/network/types"; import { @@ -44,6 +44,12 @@ import { type NetworkProfileId, } from "@/core/testing/networkProfiles"; import { networkProfileHandlers } from "@/core/testing/networkProfileHandlers"; +import { + createCaptureSink, + resetTelemetrySink, + setTelemetrySink, + type TelemetryCaptureSink +} from "@/core/telemetry/telemetry"; // --------------------------------------------------------------------------- // Types @@ -147,6 +153,16 @@ export function runAgainstNetworkProfiles( describe(`[${profile.id}] ${profile.label}`, () => { const server = withMswHandlers(...handlers); + const telemetry = createCaptureSink(); + + beforeEach(() => { + telemetry.clear(); + setTelemetrySink(telemetry); + }); + + afterEach(() => { + resetTelemetrySink(); + }); // Provide profile context to the test factory testFactory({ @@ -154,6 +170,7 @@ export function runAgainstNetworkProfiles( fixtures, server, handlers, + telemetry, }); }); }