diff --git a/.changeset/github-safe-webhook-logging.md b/.changeset/github-safe-webhook-logging.md new file mode 100644 index 00000000..4f2824c1 --- /dev/null +++ b/.changeset/github-safe-webhook-logging.md @@ -0,0 +1,7 @@ +--- +"@chat-adapter/github": patch +--- + +Remove raw GitHub webhook payload previews from adapter logs. + +Debug and error logs now report only request-shape metadata, such as body size, event type, content type, and signature presence, instead of copying provider payload content into logs. diff --git a/packages/adapter-github/src/index.test.ts b/packages/adapter-github/src/index.test.ts index 58ffa99f..ae7f75a5 100644 --- a/packages/adapter-github/src/index.test.ts +++ b/packages/adapter-github/src/index.test.ts @@ -90,6 +90,15 @@ const mockLogger = { const WEBHOOK_SECRET = "test-secret"; const INSTALLATION_ERROR_PATTERN = /installation/i; +const WEBHOOK_LOG_SENTINELS = [ + "secret-access-token", + "secret-refresh-token", + "Bearer secret-token", + "Authorization", + "customer-team-slug", + "access_token", + "refresh_token", +] as const; function signPayload(body: string): string { return `sha256=${createHmac("sha256", WEBHOOK_SECRET).update(body).digest("hex")}`; @@ -193,6 +202,22 @@ function createMockState() { }; } +function stringifyLoggerCalls(): string { + return JSON.stringify({ + debug: mockLogger.debug.mock.calls, + error: mockLogger.error.mock.calls, + info: mockLogger.info.mock.calls, + warn: mockLogger.warn.mock.calls, + }); +} + +function findLoggedSentinels(): string[] { + const logCalls = stringifyLoggerCalls(); + return WEBHOOK_LOG_SENTINELS.filter((sentinel) => + logCalls.includes(sentinel) + ); +} + // ─── Tests ─────────────────────────────────────────────────────────────────── describe("GitHubAdapter", () => { @@ -596,6 +621,43 @@ describe("GitHubAdapter", () => { expect(response.status).toBe(401); }); + it("should not log raw payload content for invalid signatures", async () => { + const body = JSON.stringify({ + action: "created", + access_token: "secret-access-token", + authorization: "Bearer secret-token", + comment: { + body: "Authorization: Bearer secret-token", + }, + refresh_token: "secret-refresh-token", + repository: { + full_name: "customer-team-slug/app", + owner: { login: "customer-team-slug" }, + }, + }); + const request = makeWebhookRequest( + body, + "issue_comment", + "sha256=invalid" + ); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(401); + expect(mockLogger.debug).toHaveBeenCalledWith( + "GitHub webhook signature verification failed", + { + bodyBytes: Buffer.byteLength(body, "utf8"), + contentType: "application/json", + eventType: "issue_comment", + signaturePresent: true, + } + ); + expect(findLoggedSentinels()).toEqual([]); + expect(stringifyLoggerCalls()).not.toContain(body); + expect(stringifyLoggerCalls()).not.toContain("GitHub webhook raw body"); + }); + it("should return 200 pong for ping event", async () => { const body = JSON.stringify({ zen: "test" }); const signature = signPayload(body); @@ -617,6 +679,90 @@ describe("GitHubAdapter", () => { expect(text).toContain("Invalid JSON"); }); + it("should not log raw payload content for invalid JSON", async () => { + const body = + "not-json secret-access-token secret-refresh-token Bearer secret-token Authorization customer-team-slug access_token refresh_token"; + const signature = signPayload(body); + const request = makeWebhookRequest(body, "issue_comment", signature); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(400); + expect(mockLogger.error).toHaveBeenCalledWith( + "GitHub webhook invalid JSON", + { + bodyBytes: Buffer.byteLength(body, "utf8"), + contentType: "application/json", + eventType: "issue_comment", + jsonParseStatus: "error", + signaturePresent: true, + } + ); + expect(findLoggedSentinels()).toEqual([]); + expect(stringifyLoggerCalls()).not.toContain(body); + expect(stringifyLoggerCalls()).not.toContain("bodyPreview"); + }); + + it("should not log raw payload content for valid webhooks", async () => { + const mockChat = { + getLogger: vi.fn(), + getState: vi.fn(), + getUserName: vi.fn(), + handleIncomingMessage: vi.fn(), + processMessage: vi.fn(), + }; + mockUsersGetAuthenticated.mockResolvedValueOnce({ + data: { id: 777, login: "test-bot" }, + }); + await adapter.initialize(mockChat); + + const basePayload = makeIssueCommentPayload(); + const payload = { + ...makeIssueCommentPayload({ + comment: { + ...basePayload.comment, + body: "Authorization: Bearer secret-token secret-access-token secret-refresh-token", + }, + repository: { + ...basePayload.repository, + full_name: "customer-team-slug/app", + owner: { + ...basePayload.repository.owner, + login: "customer-team-slug", + }, + }, + }), + access_token: "secret-access-token", + authorization: "Bearer secret-token", + refresh_token: "secret-refresh-token", + }; + const body = JSON.stringify(payload); + const signature = signPayload(body); + const request = makeWebhookRequest(body, "issue_comment", signature); + + const response = await adapter.handleWebhook(request); + + expect(response.status).toBe(200); + expect(mockChat.processMessage).toHaveBeenCalledWith( + adapter, + "github:customer-team-slug/app:42", + expect.objectContaining({ id: "100" }), + undefined + ); + expect(mockLogger.debug).toHaveBeenCalledWith( + "GitHub webhook request verified", + { + bodyBytes: Buffer.byteLength(body, "utf8"), + contentType: "application/json", + eventType: "issue_comment", + signaturePresent: true, + } + ); + expect(findLoggedSentinels()).toEqual([]); + expect(stringifyLoggerCalls()).not.toContain(body); + expect(stringifyLoggerCalls()).not.toContain("GitHub webhook raw body"); + }); + it("should process issue_comment on PR with valid signature", async () => { const mockChat = { getLogger: vi.fn(), diff --git a/packages/adapter-github/src/index.ts b/packages/adapter-github/src/index.ts index 71ad38f1..f93f71c2 100644 --- a/packages/adapter-github/src/index.ts +++ b/packages/adapter-github/src/index.ts @@ -507,19 +507,25 @@ export class GitHubAdapter options?: WebhookOptions ): Promise { const body = await request.text(); - this.logger.debug("GitHub webhook raw body", { - body: body.substring(0, 500), - }); + const signature = request.headers.get("x-hub-signature-256"); + const eventType = request.headers.get("x-github-event"); + const webhookMetadata = { + bodyBytes: Buffer.byteLength(body, "utf8"), + contentType: request.headers.get("content-type"), + eventType, + signaturePresent: signature !== null, + }; // Verify request signature - const signature = request.headers.get("x-hub-signature-256"); if (!this.verifySignature(body, signature)) { + this.logger.debug( + "GitHub webhook signature verification failed", + webhookMetadata + ); return new Response("Invalid signature", { status: 401 }); } - // Get event type from header - const eventType = request.headers.get("x-github-event"); - this.logger.debug("GitHub webhook event type", { eventType }); + this.logger.debug("GitHub webhook request verified", webhookMetadata); // Handle ping event (webhook verification) if (eventType === "ping") { @@ -535,8 +541,8 @@ export class GitHubAdapter payload = JSON.parse(body); } catch { this.logger.error("GitHub webhook invalid JSON", { - contentType: request.headers.get("content-type"), - bodyPreview: body.substring(0, 200), + ...webhookMetadata, + jsonParseStatus: "error", }); return new Response( "Invalid JSON. Make sure webhook Content-Type is set to application/json",