Skip to content

Commit c3434a8

Browse files
Merge pull request #175 from Yusufolosun/fix/stellar-log-address-redaction
fix: redact Stellar wallet addresses in logs
2 parents d45703a + 16b2ff2 commit c3434a8

4 files changed

Lines changed: 228 additions & 20 deletions

File tree

src/__test__/logRedact.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import {
3+
isLogRedactionDebugEnabled,
4+
redactLogArgs,
5+
redactLogString,
6+
redactLogValue,
7+
} from "../utils/logRedact.js";
8+
9+
const STELLAR_ADDRESS = "GCKFBEIYV2U22IO2BJ4KVJOIP7XPWQGZBW3JXDC55CYIXB5NAXMCEKJA";
10+
const STELLAR_ADDRESS_2 = "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWNB";
11+
12+
const originalDebugFlag = process.env.LOG_REDACTION_DEBUG;
13+
14+
afterEach(() => {
15+
if (originalDebugFlag === undefined) {
16+
delete process.env.LOG_REDACTION_DEBUG;
17+
} else {
18+
process.env.LOG_REDACTION_DEBUG = originalDebugFlag;
19+
}
20+
});
21+
22+
describe("logRedact", () => {
23+
it("redacts Stellar addresses inside strings", () => {
24+
const input = `wallet=${STELLAR_ADDRESS}`;
25+
const output = redactLogString(input, false);
26+
27+
expect(output).toBe("wallet=GCKFBE...EKJA");
28+
expect(output).not.toContain(STELLAR_ADDRESS);
29+
});
30+
31+
it("redacts multiple Stellar addresses in one log message", () => {
32+
const input = `${STELLAR_ADDRESS} -> ${STELLAR_ADDRESS_2}`;
33+
const output = redactLogString(input, false);
34+
35+
expect(output).toBe("GCKFBE...EKJA -> GAAZI4...CWNB");
36+
});
37+
38+
it("redacts nested objects and arrays", () => {
39+
const payload = {
40+
walletAddress: STELLAR_ADDRESS,
41+
nested: {
42+
message: `from ${STELLAR_ADDRESS_2}`,
43+
},
44+
list: [STELLAR_ADDRESS],
45+
};
46+
47+
const output = redactLogValue(payload, false);
48+
49+
expect(output.walletAddress).toBe("GCKFBE...EKJA");
50+
expect(output.nested.message).toBe("from GAAZI4...CWNB");
51+
expect(output.list[0]).toBe("GCKFBE...EKJA");
52+
});
53+
54+
it("redacts Error message and stack", () => {
55+
const error = new Error(`failed for ${STELLAR_ADDRESS}`);
56+
error.stack = `Error: failed for ${STELLAR_ADDRESS}`;
57+
58+
const output = redactLogValue(error, false);
59+
60+
expect(output.message).toContain("GCKFBE...EKJA");
61+
expect(output.message).not.toContain(STELLAR_ADDRESS);
62+
expect(output.stack).toContain("GCKFBE...EKJA");
63+
expect(output.stack).not.toContain(STELLAR_ADDRESS);
64+
});
65+
66+
it("returns original log args when debug mode is enabled", () => {
67+
const args = [
68+
`wallet=${STELLAR_ADDRESS}`,
69+
{ walletAddress: STELLAR_ADDRESS_2 },
70+
];
71+
72+
const output = redactLogArgs(args, true);
73+
74+
expect(output).toBe(args);
75+
});
76+
77+
it("reads debug mode from LOG_REDACTION_DEBUG", () => {
78+
process.env.LOG_REDACTION_DEBUG = "true";
79+
expect(isLogRedactionDebugEnabled()).toBe(true);
80+
81+
process.env.LOG_REDACTION_DEBUG = "1";
82+
expect(isLogRedactionDebugEnabled()).toBe(true);
83+
84+
process.env.LOG_REDACTION_DEBUG = "false";
85+
expect(isLogRedactionDebugEnabled()).toBe(false);
86+
});
87+
});

src/routes/webhook.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Router, type Request, type Response } from 'express';
22
import { getWebhookConfig, testWebhookConnectivity } from '../services/drawWebhookService.js';
3+
import { redactLogArgs } from '../utils/logRedact.js';
34

45
export const webhookRouter = Router();
56

@@ -46,7 +47,7 @@ webhookRouter.post('/test', async (_req: Request, res: Response) => {
4647

4748
res.status(200).json(summary);
4849
} catch (error) {
49-
console.error('[WebhookRoutes] Connectivity test failed:', error);
50+
console.error(...redactLogArgs(['[WebhookRoutes] Connectivity test failed:', error]));
5051
res.status(500).json({
5152
error: 'Internal server error',
5253
message: 'Failed to test webhook connectivity'

src/services/horizonListener.ts

Lines changed: 35 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { createHash } from "node:crypto";
2+
import { redactLogArgs } from "../utils/logRedact.js";
3+
14
// ---------------------------------------------------------------------------
25
// Types
36
// ---------------------------------------------------------------------------
@@ -105,6 +108,18 @@ const retryState = {
105108
nextRetryTime: 0,
106109
};
107110

111+
function logInfo(...args: unknown[]): void {
112+
console.log(...redactLogArgs(args));
113+
}
114+
115+
function logWarn(...args: unknown[]): void {
116+
console.warn(...redactLogArgs(args));
117+
}
118+
119+
function logError(...args: unknown[]): void {
120+
console.error(...redactLogArgs(args));
121+
}
122+
108123
// ---------------------------------------------------------------------------
109124
// Configuration helpers
110125
// ---------------------------------------------------------------------------
@@ -253,7 +268,8 @@ function calculateBackoffDelay(attempt: number, config: HorizonListenerConfig):
253268
}
254269

255270
function generateEventId(event: HorizonEvent): string {
256-
return `${event.ledger}-${event.contractId}-${event.topics.join('-')}-${event.data.slice(0, 50)}`;
271+
const dataHash = createHash("sha256").update(event.data).digest("hex").slice(0, 16);
272+
return `${event.ledger}-${event.contractId}-${event.topics.join('-')}-${dataHash}`;
257273
}
258274

259275
function isEventProcessed(eventId: string): boolean {
@@ -274,7 +290,7 @@ function markEventProcessed(eventId: string): void {
274290
function logMetrics(config: HorizonListenerConfig): void {
275291
if (!config.enableMetrics) return;
276292

277-
console.log("[HorizonListener] Metrics:", {
293+
logInfo("[HorizonListener] Metrics:", {
278294
...metrics,
279295
processedEventIdsCount: processedEventIds.size,
280296
currentLedgerCursor,
@@ -290,7 +306,7 @@ async function dispatchEvent(event: HorizonEvent): Promise<void> {
290306
if (isEventProcessed(eventId)) {
291307
metrics.eventsDuplicated++;
292308
if (activeConfig?.enableMetrics) {
293-
console.log("[HorizonListener] Skipping duplicate event:", eventId);
309+
logInfo("[HorizonListener] Skipping duplicate event:", eventId);
294310
}
295311
return;
296312
}
@@ -302,7 +318,7 @@ async function dispatchEvent(event: HorizonEvent): Promise<void> {
302318
try {
303319
await handler(event);
304320
} catch (err) {
305-
console.error(
321+
logError(
306322
"[HorizonListener] Event handler threw an error:",
307323
err,
308324
);
@@ -420,7 +436,7 @@ async function handleCursorGap(config: HorizonListenerConfig, gapStart: string):
420436
const maxGap = config.maxCursorGap || 100;
421437
const startLedger = parseInt(gapStart);
422438

423-
console.log(`[HorizonListener] Cursor gap detected at ledger ${gapStart}, attempting recovery`);
439+
logInfo(`[HorizonListener] Cursor gap detected at ledger ${gapStart}, attempting recovery`);
424440

425441
// Try to fill the gap by querying individual ledgers
426442
for (let ledger = startLedger; ledger < startLedger + maxGap && ledger <= (currentLedgerCursor || startLedger) + maxGap; ledger++) {
@@ -429,13 +445,13 @@ async function handleCursorGap(config: HorizonListenerConfig, gapStart: string):
429445
await new Promise(resolve => setTimeout(resolve, 10)); // Simulate network delay
430446

431447
if (Math.random() < 0.1) { // 10% chance of finding events in gap
432-
console.log(`[HorizonListener] Recovered events at ledger ${ledger}`);
448+
logInfo(`[HorizonListener] Recovered events at ledger ${ledger}`);
433449
metrics.cursorGapsRecovered++;
434450
break;
435451
}
436452
} catch (error) {
437453
// If we can't recover from gap, skip ahead
438-
console.warn(`[HorizonListener] Failed to recover ledger ${ledger}, skipping`);
454+
logWarn(`[HorizonListener] Failed to recover ledger ${ledger}, skipping`);
439455
break;
440456
}
441457
}
@@ -449,7 +465,7 @@ export async function pollOnce(config: HorizonListenerConfig): Promise<void> {
449465
// Check if we're in a backoff period
450466
if (retryState.nextRetryTime > Date.now()) {
451467
if (config.enableMetrics) {
452-
console.log(`[HorizonListener] In backoff period, next retry at ${new Date(retryState.nextRetryTime).toISOString()}`);
468+
logInfo(`[HorizonListener] In backoff period, next retry at ${new Date(retryState.nextRetryTime).toISOString()}`);
453469
}
454470
return;
455471
}
@@ -464,7 +480,7 @@ export async function pollOnce(config: HorizonListenerConfig): Promise<void> {
464480
const cursor = currentLedgerCursor ? `${currentLedgerCursor}` : config.startLedger;
465481

466482
if (config.enableMetrics) {
467-
console.log(
483+
logInfo(
468484
`[HorizonListener] Polling ${config.horizonUrl} ` +
469485
`(contracts: ${config.contractIds.length > 0 ? config.contractIds.join(", ") : "none"}, ` +
470486
`cursor: ${cursor})`,
@@ -490,7 +506,7 @@ export async function pollOnce(config: HorizonListenerConfig): Promise<void> {
490506
const rateLimitDelay = config.rateLimitDelayMs || 60000;
491507
retryState.nextRetryTime = Date.now() + rateLimitDelay;
492508

493-
console.warn(`[HorizonListener] Rate limit hit, waiting ${rateLimitDelay}ms`);
509+
logWarn(`[HorizonListener] Rate limit hit, waiting ${rateLimitDelay}ms`);
494510
return;
495511
}
496512

@@ -510,17 +526,17 @@ export async function pollOnce(config: HorizonListenerConfig): Promise<void> {
510526

511527
metrics.retryAttempts++;
512528

513-
console.warn(`[HorizonListener] Transient error (attempt ${retryState.attempts}/${maxRetries}), retrying in ${delay}ms:`, classifiedError.message);
529+
logWarn(`[HorizonListener] Transient error (attempt ${retryState.attempts}/${maxRetries}), retrying in ${delay}ms:`, classifiedError.message);
514530
return;
515531
} else {
516-
console.error(`[HorizonListener] Max retries exceeded for transient error:`, classifiedError);
532+
logError(`[HorizonListener] Max retries exceeded for transient error:`, classifiedError);
517533
retryState.attempts = 0; // Reset for next time
518534
return;
519535
}
520536
}
521537

522538
// Non-transient error - log and continue
523-
console.error("[HorizonListener] Non-transient error occurred:", classifiedError);
539+
logError("[HorizonListener] Non-transient error occurred:", classifiedError);
524540
}
525541
}
526542

@@ -538,15 +554,15 @@ export function getConfig(): HorizonListenerConfig | null {
538554

539555
export async function start(): Promise<void> {
540556
if (running) {
541-
console.warn("[HorizonListener] Already running — ignoring start() call.");
557+
logWarn("[HorizonListener] Already running — ignoring start() call.");
542558
return;
543559
}
544560

545561
const config = resolveConfig();
546562
activeConfig = config;
547563
running = true;
548564

549-
console.log("[HorizonListener] Starting with config:", {
565+
logInfo("[HorizonListener] Starting with config:", {
550566
horizonUrl: config.horizonUrl,
551567
contractIds: config.contractIds,
552568
pollIntervalMs: config.pollIntervalMs,
@@ -559,14 +575,14 @@ export async function start(): Promise<void> {
559575
void pollOnce(config);
560576
}, config.pollIntervalMs);
561577

562-
console.log(
578+
logInfo(
563579
`[HorizonListener] Started. Polling every ${config.pollIntervalMs}ms.`,
564580
);
565581
}
566582

567583
export function stop(): void {
568584
if (!running) {
569-
console.warn("[HorizonListener] Not running — ignoring stop() call.");
585+
logWarn("[HorizonListener] Not running — ignoring stop() call.");
570586
return;
571587
}
572588

@@ -578,5 +594,5 @@ export function stop(): void {
578594
running = false;
579595
activeConfig = null;
580596

581-
console.log("[HorizonListener] Stopped.");
582-
}
597+
logInfo("[HorizonListener] Stopped.");
598+
}

src/utils/logRedact.ts

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
const STELLAR_ADDRESS_REGEX = /\bG[A-Z2-7]{55}\b/g;
2+
3+
function truncateAddress(address: string): string {
4+
return `${address.slice(0, 6)}...${address.slice(-4)}`;
5+
}
6+
7+
function isPlainObject(value: unknown): value is Record<string, unknown> {
8+
if (Object.prototype.toString.call(value) !== '[object Object]') {
9+
return false;
10+
}
11+
12+
const prototype = Object.getPrototypeOf(value);
13+
return prototype === Object.prototype || prototype === null;
14+
}
15+
16+
export function isLogRedactionDebugEnabled(
17+
env: NodeJS.ProcessEnv = process.env,
18+
): boolean {
19+
const flag = env['LOG_REDACTION_DEBUG'];
20+
if (!flag) {
21+
return false;
22+
}
23+
24+
const normalized = flag.trim().toLowerCase();
25+
return normalized === 'true' || normalized === '1';
26+
}
27+
28+
export function redactLogString(
29+
value: string,
30+
debugEnabled = isLogRedactionDebugEnabled(),
31+
): string {
32+
if (debugEnabled) {
33+
return value;
34+
}
35+
36+
return value.replace(STELLAR_ADDRESS_REGEX, truncateAddress);
37+
}
38+
39+
function redactValueInternal(
40+
value: unknown,
41+
seen: WeakSet<object>,
42+
): unknown {
43+
if (typeof value === 'string') {
44+
return redactLogString(value, false);
45+
}
46+
47+
if (value instanceof Error) {
48+
const redactedError = new Error(redactLogString(value.message, false));
49+
redactedError.name = value.name;
50+
if (value.stack) {
51+
redactedError.stack = redactLogString(value.stack, false);
52+
}
53+
54+
const extra = value as unknown as Record<string, unknown>;
55+
for (const [key, nested] of Object.entries(extra)) {
56+
(redactedError as unknown as Record<string, unknown>)[key] =
57+
redactValueInternal(nested, seen);
58+
}
59+
60+
return redactedError;
61+
}
62+
63+
if (Array.isArray(value)) {
64+
return value.map((entry) => redactValueInternal(entry, seen));
65+
}
66+
67+
if (isPlainObject(value)) {
68+
if (seen.has(value)) {
69+
return value;
70+
}
71+
72+
seen.add(value);
73+
const redacted: Record<string, unknown> = {};
74+
for (const [key, nested] of Object.entries(value)) {
75+
redacted[key] = redactValueInternal(nested, seen);
76+
}
77+
78+
return redacted;
79+
}
80+
81+
return value;
82+
}
83+
84+
export function redactLogValue<T>(
85+
value: T,
86+
debugEnabled = isLogRedactionDebugEnabled(),
87+
): T {
88+
if (debugEnabled) {
89+
return value;
90+
}
91+
92+
return redactValueInternal(value, new WeakSet<object>()) as T;
93+
}
94+
95+
export function redactLogArgs(
96+
args: unknown[],
97+
debugEnabled = isLogRedactionDebugEnabled(),
98+
): unknown[] {
99+
if (debugEnabled) {
100+
return args;
101+
}
102+
103+
return args.map((arg) => redactLogValue(arg, false));
104+
}

0 commit comments

Comments
 (0)