Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/github-safe-webhook-logging.md
Original file line number Diff line number Diff line change
@@ -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.
146 changes: 146 additions & 0 deletions packages/adapter-github/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`;
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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);
Expand All @@ -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(),
Expand Down
24 changes: 15 additions & 9 deletions packages/adapter-github/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,19 +507,25 @@ export class GitHubAdapter
options?: WebhookOptions
): Promise<Response> {
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") {
Expand All @@ -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",
Expand Down