diff --git a/core/audit/__tests__/audit.deep-redaction.test.ts b/core/audit/__tests__/audit.deep-redaction.test.ts new file mode 100644 index 0000000..049a633 --- /dev/null +++ b/core/audit/__tests__/audit.deep-redaction.test.ts @@ -0,0 +1,331 @@ +/** + * Deep redaction and cyclic-payload tests for audit.ts — issue #147. + * + * Acceptance criteria: + * - Nested arrays and objects have secrets stripped at every depth. + * - Circular references never cause infinite recursion or unhandled throws. + * - Large strings are handled without crashing. + * - Key-case variations (SECRET, Secret, sEcReT) are all redacted. + * - A redacted audit event is always serializable with JSON.stringify. + * - `auditContext` enforces the AUDIT_MAX_CONTEXT_KEYS cap. + */ + +import { beforeEach, describe, expect, it } from "vitest"; +import { + auditContext, + createIsolatedAuditTrail, + AUDIT_MAX_CONTEXT_KEYS, + type AuditInput, +} from "@/core/audit/audit"; +import { redact, MAX_REDACT_DEPTH } from "@/core/telemetry/telemetry"; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +const AT = "2026-09-26T00:00:00.000Z"; + +function baseInput(overrides: Partial = {}): AuditInput { + return { + action: "record.state_changed", + actor: { kind: "maintainer", id: "ada" }, + scope: "maintainer", + target: { kind: "worker_job", id: "job-1" }, + at: AT, + ...overrides, + }; +} + +// ── auditContext: nested arrays ─────────────────────────────────────────────── + +describe("auditContext — nested arrays and objects", () => { + it("accepts primitive values at the top level", () => { + const ctx = auditContext({ state: "retrying", count: 3, flag: true }); + expect(ctx).toEqual({ state: "retrying", count: 3, flag: true }); + }); + + it("drops non-primitive values (objects, arrays) rather than serialising them", () => { + const ctx = auditContext({ + nested: { inner: "should be dropped" }, + arr: [1, 2, 3], + state: "ok", + }); + // nested and arr must not appear; only the primitive 'state' survives + expect(ctx).not.toHaveProperty("nested"); + expect(ctx).not.toHaveProperty("arr"); + expect(ctx).toEqual({ state: "ok" }); + }); + + it("redacts values whose key matches the sensitive-key pattern (secret, token, key)", () => { + const ctx = auditContext({ + secret: "SABC123STELLARSEED", + apiToken: "bearer-xyz", + password: "hunter2", + state: "ok", + }); + expect(ctx?.secret).toBe("[REDACTED]"); + expect(ctx?.apiToken).toBe("[REDACTED]"); + expect(ctx?.password).toBe("[REDACTED]"); + expect(ctx?.state).toBe("ok"); + }); + + it("redacts sensitive keys regardless of case (SECRET, Secret, sEcReT)", () => { + const ctx = auditContext({ + SECRET: "top-secret", + Secret: "also-secret", + sEcReT: "still-secret", + normalField: "visible", + }); + expect(ctx?.SECRET).toBe("[REDACTED]"); + expect(ctx?.Secret).toBe("[REDACTED]"); + expect(ctx?.sEcReT).toBe("[REDACTED]"); + expect(ctx?.normalField).toBe("visible"); + }); + + it("truncates string values to AUDIT_MAX_VALUE_LENGTH (160 chars)", () => { + const longValue = "x".repeat(300); + const ctx = auditContext({ payload: longValue }); + expect(ctx?.payload).toHaveLength(160); + }); + + it("scrubs embedded Stellar secret seeds inside string values", () => { + // A valid Stellar secret key is S + 55 uppercase base32 chars (A-Z, 2-7) + const seed = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3"; + const ctx = auditContext({ info: `context: ${seed} more text` }); + expect(JSON.stringify(ctx)).not.toContain(seed); + expect(ctx?.info).toContain("[REDACTED]"); + }); + + it("caps keys at AUDIT_MAX_CONTEXT_KEYS and drops the rest", () => { + const input: Record = {}; + for (let i = 0; i < AUDIT_MAX_CONTEXT_KEYS + 5; i++) { + input[`field_${i}`] = `value_${i}`; + } + const ctx = auditContext(input); + expect(Object.keys(ctx!).length).toBe(AUDIT_MAX_CONTEXT_KEYS); + }); + + it("returns undefined for an empty input object", () => { + expect(auditContext({})).toBeUndefined(); + }); + + it("returns undefined for undefined/null input", () => { + expect(auditContext(undefined)).toBeUndefined(); + }); +}); + +// ── auditContext: result is always JSON-serializable ────────────────────────── + +describe("auditContext — JSON serializability", () => { + it("always produces a JSON-serializable context", () => { + const ctx = auditContext({ + state: "retrying", + count: 5, + flag: true, + secret: "should-be-redacted", + nested: { wont: "appear" }, + }); + expect(() => JSON.stringify(ctx)).not.toThrow(); + }); +}); + +// ── Audit trail: before/after context is redacted before persistence ────────── + +describe("audit trail — redaction on record", () => { + it("stores a redacted context — no raw secrets in the persisted event", () => { + const trail = createIsolatedAuditTrail(); + const result = trail.record( + baseInput({ + before: { + state: "active", + secret: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3", + }, + after: { state: "dead_lettered" }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + + const serialized = JSON.stringify(result.value); + // The raw secret seed must not appear anywhere in the stored event + expect(serialized).not.toContain("SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"); + // The redacted placeholder must appear + expect(result.value.before?.secret).toBe("[REDACTED]"); + }); + + it("stores a redacted context for case-variant sensitive keys", () => { + const trail = createIsolatedAuditTrail(); + const result = trail.record( + baseInput({ + before: { TOKEN: "raw-bearer-abc", State: "retrying" }, + after: { state: "dead_lettered" }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.before?.TOKEN).toBe("[REDACTED]"); + }); + + it("produces a fully JSON-serializable event", () => { + const trail = createIsolatedAuditTrail(); + const result = trail.record( + baseInput({ + before: { state: "retrying", amount: 100 }, + after: { state: "dead_lettered", event: "dead_letter" }, + }) + ); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(() => JSON.stringify(result.value)).not.toThrow(); + }); +}); + +// ── redact (telemetry) — deep nested structures ─────────────────────────────── + +describe("redact — deep nested structures", () => { + it("redacts secret keys at every nesting level", () => { + const payload = { + user: { + profile: { + secret: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3", + name: "Alice", + }, + }, + token: "raw-token", + }; + const result = redact(payload) as typeof payload; + expect((result.user as any).profile.secret).toBe("[REDACTED]"); + expect((result.user as any).profile.name).toBe("Alice"); + expect((result as any).token).toBe("[REDACTED]"); + }); + + it("redacts secrets inside nested arrays", () => { + const payload = { + items: [ + { id: "a1", secret: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3" }, + { id: "a2", name: "visible" }, + ], + }; + const result = redact(payload) as any; + expect(result.items[0].secret).toBe("[REDACTED]"); + expect(result.items[1].name).toBe("visible"); + }); + + it("redacts embedded Stellar secret seeds in string values", () => { + const seed = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3"; + const result = redact({ info: `seed: ${seed}` }) as any; + expect(result.info).not.toContain(seed); + expect(result.info).toContain("[REDACTED]"); + }); + + it("truncates at MAX_REDACT_DEPTH and returns [TRUNCATED_DEPTH]", () => { + // Build an object nested deeper than MAX_REDACT_DEPTH + let deep: Record = { value: "leaf" }; + for (let i = 0; i < MAX_REDACT_DEPTH + 2; i++) { + deep = { level: deep }; + } + const result = redact(deep); + const serialized = JSON.stringify(result); + expect(serialized).toContain("TRUNCATED_DEPTH"); + }); +}); + +// ── redact — circular reference safety ─────────────────────────────────────── + +describe("redact — circular reference payloads", () => { + it("does not throw on a direct self-referencing object", () => { + const obj: Record = { id: "root" }; + obj.self = obj; // circular + expect(() => redact(obj)).not.toThrow(); + const result = redact(obj) as any; + expect(result.self).toBe("[CIRCULAR]"); + }); + + it("does not throw on a multi-hop cycle (A → B → A)", () => { + const a: Record = { name: "a" }; + const b: Record = { name: "b", ref: a }; + a.ref = b; // cycle: a.ref = b, b.ref = a + expect(() => redact(a)).not.toThrow(); + const result = redact(a) as any; + expect(result.ref.ref).toBe("[CIRCULAR]"); + }); + + it("does not throw on a deeply cyclic array", () => { + const arr: unknown[] = [1, 2]; + arr.push(arr); // arr[2] = arr + expect(() => redact(arr)).not.toThrow(); + const result = redact(arr) as unknown[]; + expect(result[2]).toBe("[CIRCULAR]"); + }); + + it("result of circular-ref redaction is always JSON-serializable", () => { + const obj: Record = { id: "node" }; + obj.self = obj; + const result = redact(obj); + expect(() => JSON.stringify(result)).not.toThrow(); + }); + + it("does not throw when a cyclic object also contains sensitive keys", () => { + const obj: Record = { id: "x", secret: "raw-secret" }; + obj.loop = obj; expect(() => redact(obj)).not.toThrow(); + const result = redact(obj) as any; + expect(result.secret).toBe("[REDACTED]"); + expect(result.loop).toBe("[CIRCULAR]"); + }); +}); + +// ── redact — large strings ──────────────────────────────────────────────────── + +describe("redact — large string handling", () => { + it("does not throw on a string larger than MAX_SCANNABLE_STRING_LENGTH", () => { + const huge = "a".repeat(600_000); + expect(() => redact(huge)).not.toThrow(); + }); + + it("truncates a string larger than MAX_SCANNABLE_STRING_LENGTH", () => { + const huge = "a".repeat(600_000); + const result = redact(huge) as string; + expect(result).toContain("[TRUNCATED]"); + expect(result.length).toBeLessThan(huge.length); + }); + + it("still redacts an embedded secret in a large string before truncation point", () => { + const seed = "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3"; + // Place the seed near the start so it's within the scannable window + const huge = seed + "b".repeat(600_000); + const result = redact(huge) as string; + expect(result).not.toContain(seed); + expect(result).toContain("[REDACTED]"); + }); +}); + +// ── Audit trail: event is serializable after cyclic-safe context ────────────── + +describe("audit trail — serializable after redaction", () => { + it("all events returned by .all() are JSON-serializable", () => { + const trail = createIsolatedAuditTrail(); + + for (let i = 0; i < 3; i++) { + trail.record( + baseInput({ + before: { state: "retrying", secret: "SAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA3" }, + after: { state: "dead_lettered" }, + at: AT, + }) + ); + } + + const events = trail.all(); + expect(() => JSON.stringify(events)).not.toThrow(); + }); + + it("export as json produces valid, parseable output", () => { + const trail = createIsolatedAuditTrail(); + trail.record(baseInput({ before: { state: "ok" }, at: AT })); + + const exported = trail.export({ kind: "maintainer", id: "ada" }, { format: "json" }); + expect(exported.ok).toBe(true); + if (!exported.ok) return; + expect(() => JSON.parse(exported.value)).not.toThrow(); + const parsed = JSON.parse(exported.value); + expect(parsed).toHaveProperty("events"); + }); +}); diff --git a/core/registry/manifests.generated.ts b/core/registry/manifests.generated.ts index 1e1bd96..dd45838 100644 --- a/core/registry/manifests.generated.ts +++ b/core/registry/manifests.generated.ts @@ -17,6 +17,7 @@ import { manifest as balanceViewer } from "@/features/balance-viewer/manifest"; import { manifest as batchAddressValidator } from "@/features/batch-address-validator/manifest"; import { manifest as claimableBalances } from "@/features/claimable-balances/manifest"; import { manifest as claimablePredicateBuilder } from "@/features/claimable-predicate-builder/manifest"; +import { manifest as contractEvents } from "@/features/contract-events/manifest"; import { manifest as effectsTimeline } from "@/features/effects-timeline/manifest"; import { manifest as federationResolver } from "@/features/federation-resolver/manifest"; import { manifest as feeBumpInspector } from "@/features/fee-bump-inspector/manifest"; @@ -35,9 +36,12 @@ import { manifest as networkComparison } from "@/features/network-comparison/man import { manifest as operationBrowser } from "@/features/operation-browser/manifest"; import { manifest as operationSourceMap } from "@/features/operation-source-map/manifest"; import { manifest as orderbookViewer } from "@/features/orderbook-viewer/manifest"; +import { manifest as pathPaymentFinder } from "@/features/path-payment-finder/manifest"; import { manifest as pathPaymentInspector } from "@/features/path-payment-inspector/manifest"; import { manifest as paymentCsvPreflight } from "@/features/payment-csv-preflight/manifest"; +import { manifest as paymentHistory } from "@/features/payment-history/manifest"; import { manifest as paymentQr } from "@/features/payment-qr/manifest"; +import { manifest as paymentUriParser } from "@/features/payment-uri-parser/manifest"; import { manifest as preconditionsExplainer } from "@/features/preconditions-explainer/manifest"; import { manifest as priceFractionLab } from "@/features/price-fraction-lab/manifest"; import { manifest as reserveCalculator } from "@/features/reserve-calculator/manifest"; @@ -79,6 +83,7 @@ export const generatedManifests: FeatureManifest[] = [ batchAddressValidator, claimableBalances, claimablePredicateBuilder, + contractEvents, effectsTimeline, federationResolver, feeBumpInspector, @@ -97,9 +102,12 @@ export const generatedManifests: FeatureManifest[] = [ operationBrowser, operationSourceMap, orderbookViewer, + pathPaymentFinder, pathPaymentInspector, paymentCsvPreflight, + paymentHistory, paymentQr, + paymentUriParser, preconditionsExplainer, priceFractionLab, reserveCalculator, diff --git a/core/registry/panels.generated.ts b/core/registry/panels.generated.ts index 827582a..9fe5172 100644 --- a/core/registry/panels.generated.ts +++ b/core/registry/panels.generated.ts @@ -20,6 +20,7 @@ export const generatedPanels: Record = { "batch-address-validator": () => import("@/features/batch-address-validator/panel").then((mod) => mod.default as ComponentType), "claimable-balances": () => import("@/features/claimable-balances/panel").then((mod) => mod.default as ComponentType), "claimable-predicate-builder": () => import("@/features/claimable-predicate-builder/panel").then((mod) => mod.default as ComponentType), + "contract-events": () => import("@/features/contract-events/panel").then((mod) => mod.default as ComponentType), "effects-timeline": () => import("@/features/effects-timeline/panel").then((mod) => mod.default as ComponentType), "federation-resolver": () => import("@/features/federation-resolver/panel").then((mod) => mod.default as ComponentType), "fee-bump-inspector": () => import("@/features/fee-bump-inspector/panel").then((mod) => mod.default as ComponentType), @@ -38,9 +39,12 @@ export const generatedPanels: Record = { "operation-browser": () => import("@/features/operation-browser/panel").then((mod) => mod.default as ComponentType), "operation-source-map": () => import("@/features/operation-source-map/panel").then((mod) => mod.default as ComponentType), "orderbook-viewer": () => import("@/features/orderbook-viewer/panel").then((mod) => mod.default as ComponentType), + "path-payment-finder": () => import("@/features/path-payment-finder/panel").then((mod) => mod.default as ComponentType), "path-payment-inspector": () => import("@/features/path-payment-inspector/panel").then((mod) => mod.default as ComponentType), "payment-csv-preflight": () => import("@/features/payment-csv-preflight/panel").then((mod) => mod.default as ComponentType), + "payment-history": () => import("@/features/payment-history/panel").then((mod) => mod.default as ComponentType), "payment-qr": () => import("@/features/payment-qr/panel").then((mod) => mod.default as ComponentType), + "payment-uri-parser": () => import("@/features/payment-uri-parser/panel").then((mod) => mod.default as ComponentType), "preconditions-explainer": () => import("@/features/preconditions-explainer/panel").then((mod) => mod.default as ComponentType), "price-fraction-lab": () => import("@/features/price-fraction-lab/panel").then((mod) => mod.default as ComponentType), "reserve-calculator": () => import("@/features/reserve-calculator/panel").then((mod) => mod.default as ComponentType), diff --git a/core/registry/registry.generated.ts b/core/registry/registry.generated.ts index a1ebb77..f164fa1 100644 --- a/core/registry/registry.generated.ts +++ b/core/registry/registry.generated.ts @@ -18,6 +18,7 @@ import { manifest as balanceViewer } from "@/features/balance-viewer/manifest"; import { manifest as batchAddressValidator } from "@/features/batch-address-validator/manifest"; import { manifest as claimableBalances } from "@/features/claimable-balances/manifest"; import { manifest as claimablePredicateBuilder } from "@/features/claimable-predicate-builder/manifest"; +import { manifest as contractEvents } from "@/features/contract-events/manifest"; import { manifest as effectsTimeline } from "@/features/effects-timeline/manifest"; import { manifest as federationResolver } from "@/features/federation-resolver/manifest"; import { manifest as feeBumpInspector } from "@/features/fee-bump-inspector/manifest"; @@ -36,9 +37,12 @@ import { manifest as networkComparison } from "@/features/network-comparison/man import { manifest as operationBrowser } from "@/features/operation-browser/manifest"; import { manifest as operationSourceMap } from "@/features/operation-source-map/manifest"; import { manifest as orderbookViewer } from "@/features/orderbook-viewer/manifest"; +import { manifest as pathPaymentFinder } from "@/features/path-payment-finder/manifest"; import { manifest as pathPaymentInspector } from "@/features/path-payment-inspector/manifest"; import { manifest as paymentCsvPreflight } from "@/features/payment-csv-preflight/manifest"; +import { manifest as paymentHistory } from "@/features/payment-history/manifest"; import { manifest as paymentQr } from "@/features/payment-qr/manifest"; +import { manifest as paymentUriParser } from "@/features/payment-uri-parser/manifest"; import { manifest as preconditionsExplainer } from "@/features/preconditions-explainer/manifest"; import { manifest as priceFractionLab } from "@/features/price-fraction-lab/manifest"; import { manifest as reserveCalculator } from "@/features/reserve-calculator/manifest"; @@ -80,6 +84,7 @@ export const generatedFeatures: FeatureEntry[] = [ { manifest: batchAddressValidator, load: async () => { const mod = await import("@/features/batch-address-validator/panel"); return mod.default as ComponentType; } }, { manifest: claimableBalances, load: async () => { const mod = await import("@/features/claimable-balances/panel"); return mod.default as ComponentType; } }, { manifest: claimablePredicateBuilder, load: async () => { const mod = await import("@/features/claimable-predicate-builder/panel"); return mod.default as ComponentType; } }, + { manifest: contractEvents, load: async () => { const mod = await import("@/features/contract-events/panel"); return mod.default as ComponentType; } }, { manifest: effectsTimeline, load: async () => { const mod = await import("@/features/effects-timeline/panel"); return mod.default as ComponentType; } }, { manifest: federationResolver, load: async () => { const mod = await import("@/features/federation-resolver/panel"); return mod.default as ComponentType; } }, { manifest: feeBumpInspector, load: async () => { const mod = await import("@/features/fee-bump-inspector/panel"); return mod.default as ComponentType; } }, @@ -98,9 +103,12 @@ export const generatedFeatures: FeatureEntry[] = [ { manifest: operationBrowser, load: async () => { const mod = await import("@/features/operation-browser/panel"); return mod.default as ComponentType; } }, { manifest: operationSourceMap, load: async () => { const mod = await import("@/features/operation-source-map/panel"); return mod.default as ComponentType; } }, { manifest: orderbookViewer, load: async () => { const mod = await import("@/features/orderbook-viewer/panel"); return mod.default as ComponentType; } }, + { manifest: pathPaymentFinder, load: async () => { const mod = await import("@/features/path-payment-finder/panel"); return mod.default as ComponentType; } }, { manifest: pathPaymentInspector, load: async () => { const mod = await import("@/features/path-payment-inspector/panel"); return mod.default as ComponentType; } }, { manifest: paymentCsvPreflight, load: async () => { const mod = await import("@/features/payment-csv-preflight/panel"); return mod.default as ComponentType; } }, + { manifest: paymentHistory, load: async () => { const mod = await import("@/features/payment-history/panel"); return mod.default as ComponentType; } }, { manifest: paymentQr, load: async () => { const mod = await import("@/features/payment-qr/panel"); return mod.default as ComponentType; } }, + { manifest: paymentUriParser, load: async () => { const mod = await import("@/features/payment-uri-parser/panel"); return mod.default as ComponentType; } }, { manifest: preconditionsExplainer, load: async () => { const mod = await import("@/features/preconditions-explainer/panel"); return mod.default as ComponentType; } }, { manifest: priceFractionLab, load: async () => { const mod = await import("@/features/price-fraction-lab/panel"); return mod.default as ComponentType; } }, { manifest: reserveCalculator, load: async () => { const mod = await import("@/features/reserve-calculator/panel"); return mod.default as ComponentType; } },