diff --git a/apps/api/src/auth/services/auth.interceptor.ts b/apps/api/src/auth/services/auth.interceptor.ts index e3da8a678d..c071dfd2c1 100644 --- a/apps/api/src/auth/services/auth.interceptor.ts +++ b/apps/api/src/auth/services/auth.interceptor.ts @@ -72,7 +72,7 @@ export class AuthInterceptor implements HonoInterceptor { return await next(); } catch (error) { - this.logger.error(error); + this.logger.warn({ event: "API_KEY_VALIDATION_FAILED", message: error instanceof Error ? error.message : "Unknown error" }); throw new Unauthorized("Invalid API key"); } } diff --git a/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.tsx b/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.tsx index 16f4324619..6a7fa7d924 100644 --- a/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.tsx +++ b/apps/deploy-web/src/components/billing-usage/TrendIndicator/TrendIndicator.tsx @@ -41,7 +41,7 @@ export const TrendIndicator = , Data extends H if (firstValue === 0) return null; const percentageChange = ((lastValue - firstValue) / firstValue) * 100; - const isCurrentDay = isToday(new Date(lastItem.date)); + const isCurrentDay = isToday(new Date(`${lastItem.date}T00:00:00`)); return { change: Math.round(percentageChange * 100) / 100, diff --git a/apps/deploy-web/src/pages/api/auth/password-login.ts b/apps/deploy-web/src/pages/api/auth/password-login.ts index 17dea9df82..68c2c76244 100644 --- a/apps/deploy-web/src/pages/api/auth/password-login.ts +++ b/apps/deploy-web/src/pages/api/auth/password-login.ts @@ -34,7 +34,8 @@ export default defineApiHandler({ const { cause, ...errorDetails } = result.val; services.logger.warn({ event: "PASSWORD_LOGIN_ERROR", - cause: result.val + code: errorDetails.code, + message: errorDetails.message }); return res.status(400).json(errorDetails); } diff --git a/apps/deploy-web/src/pages/api/auth/password-signup.ts b/apps/deploy-web/src/pages/api/auth/password-signup.ts index 74e25026ca..03a9684855 100644 --- a/apps/deploy-web/src/pages/api/auth/password-signup.ts +++ b/apps/deploy-web/src/pages/api/auth/password-signup.ts @@ -56,7 +56,8 @@ export default defineApiHandler({ const { cause, ...errorDetails } = result.val; services.logger.warn({ event: "PASSWORD_SIGNUP_ERROR", - cause: result.val + code: errorDetails.code, + message: errorDetails.message }); if (result.val.code === "user_exists") { diff --git a/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts b/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts index cf297e8e60..abc5cc1994 100644 --- a/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts +++ b/apps/deploy-web/src/pages/api/auth/send-password-reset-email.ts @@ -33,7 +33,8 @@ export default defineApiHandler({ const { cause, ...errorDetails } = result.val; services.logger.warn({ event: "SEND_PASSWORD_RESET_EMAIL_ERROR", - cause: result.val + code: errorDetails.code, + message: errorDetails.message }); return res.status(400).json(errorDetails); } catch (error) { diff --git a/apps/deploy-web/src/services/error-handler/error-handler.service.spec.ts b/apps/deploy-web/src/services/error-handler/error-handler.service.spec.ts index 0ebae5773a..eafdaa8bd2 100644 --- a/apps/deploy-web/src/services/error-handler/error-handler.service.spec.ts +++ b/apps/deploy-web/src/services/error-handler/error-handler.service.spec.ts @@ -66,6 +66,51 @@ describe(ErrorHandlerService.name, () => { }); }); + it("filters out sensitive headers from HTTP error response", () => { + const captureException = vi.fn().mockReturnValue("event-id-3"); + const errorHandler = setup({ captureException }); + + const config = { + method: "post", + url: "https://api.example.com/auth" + } as InternalAxiosRequestConfig; + const httpError = new AxiosError( + "Request failed", + "401", + config, + {}, + { + status: 401, + statusText: "Unauthorized", + headers: { + "content-type": "application/json", + "set-cookie": "session=secret-token; HttpOnly", + authorization: "Bearer secret-jwt", + server: "nginx" + }, + data: {}, + config: config + } + ); + + errorHandler.reportError({ error: httpError }); + + expect(captureException).toHaveBeenCalledWith(httpError, { + level: "error", + extra: { + headers: { + "content-type": "application/json", + server: "nginx" + } + }, + tags: { + status: "401", + method: "POST", + url: "https://api.example.com/auth" + } + }); + }); + describe("wrapCallback", () => { it("wraps synchronous function and reports error", () => { const captureException = vi.fn(); diff --git a/apps/deploy-web/src/services/error-handler/error-handler.service.ts b/apps/deploy-web/src/services/error-handler/error-handler.service.ts index d0110f9f80..05ce9b5da1 100644 --- a/apps/deploy-web/src/services/error-handler/error-handler.service.ts +++ b/apps/deploy-web/src/services/error-handler/error-handler.service.ts @@ -31,7 +31,7 @@ export class ErrorHandlerService { finalTags.status = error.response.status.toString(); finalTags.method = error.response.config.method?.toUpperCase() || "UNKNOWN"; finalTags.url = error.response.config.url || "UNKNOWN"; - extra.headers = error.response.headers; + extra.headers = pickSafeHeaders(error.response.headers); } this.logger.error({ ...extra, ...finalTags, error }); @@ -81,6 +81,18 @@ export interface TraceData { baggage?: string; } +const SAFE_HEADERS = new Set(["content-type", "content-length", "x-request-id", "x-correlation-id", "retry-after", "server"]); + +function pickSafeHeaders(headers: Record): Record { + const safe: Record = {}; + for (const key of Object.keys(headers)) { + if (SAFE_HEADERS.has(key.toLowerCase())) { + safe[key] = headers[key]; + } + } + return safe; +} + /** * Converts Sentry `sentry-trace` header value into a valid W3C `traceparent`. * diff --git a/apps/deploy-web/src/services/session/session.service.ts b/apps/deploy-web/src/services/session/session.service.ts index 36ad59f229..726d67b380 100644 --- a/apps/deploy-web/src/services/session/session.service.ts +++ b/apps/deploy-web/src/services/session/session.service.ts @@ -244,7 +244,6 @@ function extractResponseDetails(response: AxiosResponse) { url: response.config?.url, method: response.config?.method, status: response.status, - data: response.data, headers: { "Content-Type": response.headers["content-type"], server: response.headers["server"] diff --git a/packages/logging/src/hono/http-logger/http-logger.service.ts b/packages/logging/src/hono/http-logger/http-logger.service.ts index 399dd9f5a8..71971e1442 100644 --- a/packages/logging/src/hono/http-logger/http-logger.service.ts +++ b/packages/logging/src/hono/http-logger/http-logger.service.ts @@ -18,6 +18,11 @@ type HttpRequestLog = { userId?: string; }; +function stripQueryParams(url: string): string { + const index = url.indexOf("?"); + return index === -1 ? url : url.slice(0, index); +} + export class HttpLoggerInterceptor { constructor(private readonly logger?: LoggerService) {} @@ -34,7 +39,7 @@ export class HttpLoggerInterceptor { const log: HttpRequestLog = { httpRequest: { requestMethod: c.req.method, - requestUrl: c.req.url, + requestUrl: stripQueryParams(c.req.url), status: c.res.status, referrer: c.req.raw.referrer, protocol: c.req.header("x-forwarded-proto"), diff --git a/packages/logging/src/services/logger/logger.service.spec.ts b/packages/logging/src/services/logger/logger.service.spec.ts index 68bb680dc4..5e88bfc5cf 100644 --- a/packages/logging/src/services/logger/logger.service.spec.ts +++ b/packages/logging/src/services/logger/logger.service.spec.ts @@ -18,6 +18,10 @@ describe("LoggerService", () => { level: expect.any(Function) }, serializers: expect.any(Object), + redact: { + paths: expect.arrayContaining(["*.password", "*.access_token", "*.refresh_token"]), + censor: "[REDACTED]" + }, browser: { formatters: { level: expect.any(Function) @@ -119,7 +123,7 @@ describe("LoggerService", () => { status: 404, message: "Not found", stack: "stack trace", - data: { key: "value" } + data: { errorCode: "not_found", errorType: "client_error", secret: "should-be-filtered" } }); logger.info(httpError); @@ -129,7 +133,7 @@ describe("LoggerService", () => { status: 404, message: "Not found", stack: "stack trace", - data: { key: "value" } + data: { errorCode: "not_found", errorType: "client_error" } }) }) ); @@ -141,7 +145,7 @@ describe("LoggerService", () => { status: 404, message: "Not found", stack: "stack trace", - data: { key: "value" }, + data: { errorCode: "not_found", errorType: "client_error" }, originalError: new Error("Original error"), cause: new Error("Cause error") }); @@ -153,7 +157,7 @@ describe("LoggerService", () => { status: 404, message: "Not found", stack: expect.stringContaining("stack trace\n\nCaused by:\n Error: Cause error"), - data: { key: "value" }, + data: { errorCode: "not_found", errorType: "client_error" }, originalError: expect.stringContaining("Error: Original error") }) }) @@ -166,7 +170,7 @@ describe("LoggerService", () => { status: 404, message: "Not found", stack: "stack trace", - data: { key: "value" }, + data: { errorCode: "not_found" }, originalError: new Error("Original error") }); @@ -177,7 +181,7 @@ describe("LoggerService", () => { status: 404, message: "Not found", stack: "stack trace", - data: { key: "value" }, + data: { errorCode: "not_found", errorType: undefined }, originalError: expect.stringContaining("Error: Original error") }) }) @@ -294,21 +298,61 @@ describe("LoggerService", () => { ); }); - it("should collect sql from error", () => { + it("should collect sql from error with redacted literals", () => { const { logger, logs } = setup(); const error = new Error("Test error"); - Object.assign(error, { sql: "SELECT * FROM users" }); + Object.assign(error, { sql: "SELECT * FROM users WHERE name = 'John' AND id = 1234567" }); logger.info(error); expect(logs[0]).toEqual( expect.objectContaining({ err: expect.objectContaining({ - sql: "SELECT * FROM users", + sql: "SELECT * FROM users WHERE name = '[REDACTED]' AND id = [REDACTED]", stack: expect.stringContaining("Test error") }) }) ); }); + describe("redaction", () => { + it("should redact sensitive fields like password and tokens", () => { + const { logger, logs } = setup(); + logger.info({ event: "TEST", credentials: { password: "s3cret", access_token: "tok_123", name: "safe" } }); + expect(logs[0]).toEqual( + expect.objectContaining({ + credentials: expect.objectContaining({ + password: "[REDACTED]", + access_token: "[REDACTED]", + name: "safe" + }) + }) + ); + }); + + it("should redact authorization and cookie headers", () => { + const { logger, logs } = setup(); + logger.info({ event: "TEST", headers: { authorization: "Bearer secret", "set-cookie": "sid=abc", "content-type": "application/json" } }); + expect(logs[0]).toEqual( + expect.objectContaining({ + headers: expect.objectContaining({ + authorization: "[REDACTED]", + "set-cookie": "[REDACTED]", + "content-type": "application/json" + }) + }) + ); + }); + + it("should not redact non-sensitive fields", () => { + const { logger, logs } = setup(); + logger.info({ event: "TEST", data: { status: 200, url: "/api/test" } }); + expect(logs[0]).toEqual( + expect.objectContaining({ + data: { status: 200, url: "/api/test" } + }) + ); + }); + }); + function setup() { const logs: unknown[] = []; const logger = new LoggerService({ diff --git a/packages/logging/src/services/logger/logger.service.ts b/packages/logging/src/services/logger/logger.service.ts index 37021d349f..7b6e926e6e 100644 --- a/packages/logging/src/services/logger/logger.service.ts +++ b/packages/logging/src/services/logger/logger.service.ts @@ -83,6 +83,32 @@ export class LoggerService implements Logger { msg: sanitizeString, message: sanitizeString }, + redact: { + paths: [ + "*.password", + "*.access_token", + "*.refresh_token", + "*.id_token", + "*.token", + "*.accessToken", + "*.refreshToken", + "*.idToken", + "*.client_secret", + "*.clientSecret", + "*.authorization", + "*.Authorization", + '*["set-cookie"]', + '*["Set-Cookie"]', + "*.cookie", + "*.Cookie", + "*.apiKey", + "*.api_key", + '*["x-api-key"]', + "cause.data", + "*.cause.data" + ], + censor: "[REDACTED]" + }, ...additionalOptions, browser: { formatters, @@ -182,7 +208,7 @@ function logError(error: Error | undefined | null) { status: error.status, message: sanitizeString(error.message), stack: collectFullErrorStack(error), - data: error.data, + data: error.data ? { errorCode: error.data.errorCode, errorType: error.data.errorType } : undefined, originalError: error.originalError ? collectFullErrorStack(error.originalError) : undefined }; } @@ -190,11 +216,15 @@ function logError(error: Error | undefined | null) { if (Object.hasOwn(error, "sql")) { return { stack: collectFullErrorStack(error), - sql: (error as Error & { sql: string }).sql + sql: redactSqlLiterals((error as Error & { sql: string }).sql) }; } return collectFullErrorStack(error); } +function redactSqlLiterals(sql: string): string { + return sql.replace(/'(?:[^'\\]|\\.)*'/g, "'[REDACTED]'").replace(/\b\d{6,}\b/g, "[REDACTED]"); +} + declare let window: unknown; diff --git a/packages/logging/src/utils/collect-full-error-stack/collect-full-error-stack.ts b/packages/logging/src/utils/collect-full-error-stack/collect-full-error-stack.ts index 8a5c42041f..644491b829 100644 --- a/packages/logging/src/utils/collect-full-error-stack/collect-full-error-stack.ts +++ b/packages/logging/src/utils/collect-full-error-stack/collect-full-error-stack.ts @@ -24,13 +24,11 @@ export function collectFullErrorStack(error: string | Error | AggregateError | E const requestedPath = currentError.response.config?.url ? `${currentError.response.config.method?.toUpperCase() || "(HTTP method not specified)"} ${currentError.response.config?.url}` : "Unknown request"; - const body = currentError.response.data ? JSON.stringify(currentError.response.data) : "No body"; stack.push( "\nResponse:", `\nRequest: ${requestedPath}`, `\nStatus: ${currentError.response.status}`, - `\nError: ${sanitizeString(currentError.response.data?.message) || "Not specified"} (code: ${currentError.response.data?.code || "Not specified"})`, - `\nBody: ${body.length > 200 ? `${body.slice(0, 200)}...` : body}` + `\nError: ${sanitizeString(currentError.response.data?.message) || "Not specified"} (code: ${currentError.response.data?.code || "Not specified"})` ); }