diff --git a/README.md b/README.md index c9c3a2f..a5a023f 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,9 @@ TypeScript client library for the [Iterable API](https://api.iterable.com/api/docs). -**Important:** This library is maintained for use in the [Iterable MCP server](https://github.com/Iterable/mcp-server) and internal Iterable projects. The API may change at any time. If using this library directly in your own projects, be prepared to adapt to breaking changes. +This library is currently in active development. While it is used in production by the [Iterable MCP server](https://github.com/Iterable/mcp-server), it is still considered experimental. We are rapidly iterating on features and improvements, so you may encounter breaking changes or incomplete type definitions. -Pull requests for bugfixes and improvements are always welcome and appreciated. +We welcome early adopters and feedback! If you're building with it, please stay in touch via issues or pull requests. ## Installation @@ -48,7 +48,8 @@ const client = new IterableClient({ apiKey: 'your-api-key', baseUrl: 'https://api.iterable.com', // optional timeout: 30000, // optional - debug: false // optional + debug: true, // log requests/responses (headers/params redacted) + debugVerbose: false // set true to log response bodies (CAUTION: may contain PII) }); ``` @@ -56,9 +57,10 @@ const client = new IterableClient({ ```bash ITERABLE_API_KEY=your-api-key # Required -ITERABLE_DEBUG=true # Optional: Enable debug logging -LOG_LEVEL=info # Optional: Log level -LOG_FILE=./logs/iterable.log # Optional: Log to file +ITERABLE_DEBUG=true # Enable basic debug logging +ITERABLE_DEBUG_VERBOSE=true # Enable full body logging (includes PII) +LOG_LEVEL=info # Log level +LOG_FILE=./logs/iterable.log # Log to file ``` ## Development diff --git a/src/client/base.ts b/src/client/base.ts index ab6f322..b48afe3 100644 --- a/src/client/base.ts +++ b/src/client/base.ts @@ -80,19 +80,32 @@ export class BaseIterableClient { ); // Add debug logging interceptors (only when debug is enabled) + // WARNING: Never enable debug mode in production as it may log sensitive information if (clientConfig.debug) { + const sanitizeHeaders = (headers: any) => { + if (!headers) return undefined; + const sensitive = ["api-key", "authorization", "cookie", "set-cookie"]; + const sanitized = { ...headers }; + Object.keys(sanitized).forEach((key) => { + if (sensitive.includes(key.toLowerCase())) { + sanitized[key] = "[REDACTED]"; + } + }); + return sanitized; + }; + this.client.interceptors.request.use((request) => { logger.debug("API request", { method: request.method?.toUpperCase(), url: request.url, + headers: sanitizeHeaders(request.headers), }); return request; }); - // Helper function to create log data from response/error const createResponseLogData = (response: any, includeData = false) => ({ status: response.status, - url: response.config?.url || response.config.url, + url: response.config?.url, ...(includeData && { data: response.data }), }); @@ -106,14 +119,14 @@ export class BaseIterableClient { }, (error) => { if (error.response) { + // CRITICAL: Only log response data if verbose debug is enabled to prevent PII leaks logger.error( "API error", - createResponseLogData(error.response, true) + createResponseLogData(error.response, clientConfig.debugVerbose) ); } else { logger.error("Network error", { message: error.message }); } - // Error is already converted by the error handling interceptor above return Promise.reject(error); } ); diff --git a/tests/unit/sanitization.test.ts b/tests/unit/sanitization.test.ts new file mode 100644 index 0000000..dbc0843 --- /dev/null +++ b/tests/unit/sanitization.test.ts @@ -0,0 +1,173 @@ +import { beforeEach, describe, expect, it, jest } from "@jest/globals"; +import axios from "axios"; + +// Automock axios +jest.mock("axios"); +const mockedAxios = axios as jest.Mocked; + +import { BaseIterableClient } from "../../src/client/base.js"; +// Import the real logger to spy on it +import { logger } from "../../src/logger.js"; + +describe("Debug Logging Sanitization", () => { + let mockClientInstance: any; + let requestInterceptor: any; + let responseInterceptorError: any; + + let debugSpy: any; + let errorSpy: any; + + beforeEach(() => { + jest.clearAllMocks(); + + // Spy on logger methods + // We use mockImplementation to silence the console output during tests + debugSpy = jest.spyOn(logger, "debug").mockImplementation(() => logger); + errorSpy = jest.spyOn(logger, "error").mockImplementation(() => logger); + + requestInterceptor = undefined; + responseInterceptorError = undefined; + + mockClientInstance = { + interceptors: { + request: { + use: jest.fn((callback) => { + requestInterceptor = callback; + return 0; + }), + }, + response: { + use: jest.fn((success, error) => { + responseInterceptorError = error; + return 0; + }), + }, + }, + get: jest.fn(), + defaults: { headers: {} }, + }; + + if (jest.isMockFunction(mockedAxios.create)) { + mockedAxios.create.mockReturnValue(mockClientInstance); + } else { + (mockedAxios as any).create = jest.fn().mockReturnValue(mockClientInstance); + } + }); + + it("should call axios.create and register interceptors", () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + }); + + expect(mockedAxios.create).toHaveBeenCalled(); + expect(mockClientInstance.interceptors.request.use).toHaveBeenCalled(); + expect(requestInterceptor).toBeDefined(); + }); + + it("should redact sensitive headers in debug logs", () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + }); + + if (!requestInterceptor) throw new Error("Request interceptor missing"); + + const requestConfig = { + method: "get", + url: "/test", + headers: { + Authorization: "Bearer secret-token", + "Cookie": "session=secret", + "X-Custom": "safe", + "Api-Key": "real-api-key", + }, + }; + + requestInterceptor(requestConfig); + + expect(debugSpy).toHaveBeenCalledWith( + "API request", + expect.objectContaining({ + headers: expect.objectContaining({ + "Api-Key": "[REDACTED]", + Authorization: "[REDACTED]", + Cookie: "[REDACTED]", + "X-Custom": "safe", + }), + }) + ); + }); + + it("should NOT log error response data by default (debugVerbose=false)", async () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + debugVerbose: false, + }); + + if (!responseInterceptorError) throw new Error("Response interceptor missing"); + + const sensitiveError = { message: "User email@example.com not found" }; + const errorResponse = { + response: { + status: 404, + config: { url: "/error" }, + data: sensitiveError, + }, + }; + + try { + await responseInterceptorError(errorResponse); + } catch { + // Expected + } + + expect(errorSpy).toHaveBeenCalledWith( + "API error", + expect.objectContaining({ + status: 404, + }) + ); + + const errorLog = errorSpy.mock.calls.find( + (call: any) => call[0] === "API error" + ); + const errorData = errorLog?.[1] as any; + + expect(errorData.data).toBeUndefined(); + }); + + it("should log error response data when debugVerbose is true", async () => { + new BaseIterableClient({ + apiKey: "test-api-key", + debug: true, + debugVerbose: true, + }); + + if (!responseInterceptorError) throw new Error("Response interceptor missing"); + + const errorBody = { error: "details" }; + const errorResponse = { + response: { + status: 400, + config: { url: "/error" }, + data: errorBody, + }, + }; + + try { + await responseInterceptorError(errorResponse); + } catch { + // Expected + } + + expect(errorSpy).toHaveBeenCalledWith( + "API error", + expect.objectContaining({ + status: 400, + data: errorBody, + }) + ); + }); +});