mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-08-03 09:48:31 +08:00
fix: filter PR reviews and inline review comments to trigger time (#1385)
Issue/PR comments (#512) and the issue/PR body (#710) are filtered to the trigger timestamp so content created or edited after an authorized trigger cannot be injected into Claude's prompt (TOCTOU protection). Reviews and inline review comments were not: fetchGitHubData returned reviewData filtered by actor only, and formatReviewComments renders it into the prompt, so a review submitted or edited after the trigger reached Claude verbatim. filterReviewsToTriggerTime already existed (added alongside the comment filter in #512) but was only wired to the image-download list, never to the returned reviewData. Filter reviewData.nodes through filterReviewsToTriggerTime and each review's inline comments through filterCommentsToTriggerTime, alongside the existing actor filter, then build the review image-processing lists from those already-filtered nodes (removing a now-redundant second filter pass). Strengthen the two integration tests to assert post-trigger and edited-after reviews/comments are dropped.
This commit is contained in:
parent
30544b6743
commit
6b8063043e
@ -378,34 +378,26 @@ export async function fetchGitHubData({
|
|||||||
body: c.body,
|
body: c.body,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Filter review bodies to trigger time
|
// Filter reviews and inline review comments to trigger time and by actor
|
||||||
const filteredReviewBodies = reviewData?.nodes
|
// before building anything from them. The trigger-time filter is the TOCTOU
|
||||||
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
|
// protection applied to issue/PR comments and the body above: it drops
|
||||||
(r) => r.body,
|
// anything submitted, created, or edited at/after the trigger so an attacker
|
||||||
)
|
// cannot inject content into the prompt after an authorized trigger. Without
|
||||||
: [];
|
// it, review bodies and inline review comments would reach the prompt
|
||||||
|
// verbatim regardless of when they landed.
|
||||||
const reviewBodies: CommentWithImages[] = filteredReviewBodies.map((r) => ({
|
|
||||||
type: "review_body" as const,
|
|
||||||
id: r.databaseId,
|
|
||||||
pullNumber: prNumber,
|
|
||||||
body: r.body,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Filter review comments to trigger time and by actor
|
|
||||||
if (reviewData && reviewData.nodes) {
|
if (reviewData && reviewData.nodes) {
|
||||||
// Filter reviews by actor
|
// Drop reviews submitted or edited after the trigger, then filter by actor.
|
||||||
reviewData.nodes = filterCommentsByActor(
|
reviewData.nodes = filterCommentsByActor(
|
||||||
reviewData.nodes,
|
filterReviewsToTriggerTime(reviewData.nodes, triggerTime),
|
||||||
includeCommentsByActor,
|
includeCommentsByActor,
|
||||||
excludeCommentsByActor,
|
excludeCommentsByActor,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Also filter inline review comments within each review
|
// Apply the same trigger-time + actor filtering to inline review comments.
|
||||||
reviewData.nodes.forEach((review) => {
|
reviewData.nodes.forEach((review) => {
|
||||||
if (review.comments?.nodes) {
|
if (review.comments?.nodes) {
|
||||||
review.comments.nodes = filterCommentsByActor(
|
review.comments.nodes = filterCommentsByActor(
|
||||||
review.comments.nodes,
|
filterCommentsToTriggerTime(review.comments.nodes, triggerTime),
|
||||||
includeCommentsByActor,
|
includeCommentsByActor,
|
||||||
excludeCommentsByActor,
|
excludeCommentsByActor,
|
||||||
);
|
);
|
||||||
@ -413,14 +405,19 @@ export async function fetchGitHubData({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const allReviewComments =
|
// Build the image-processing lists from the already-filtered review nodes,
|
||||||
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
|
// so reviews/comments excluded from the prompt are not processed for images.
|
||||||
const filteredReviewComments = filterCommentsToTriggerTime(
|
const reviewBodies: CommentWithImages[] = (reviewData?.nodes ?? [])
|
||||||
allReviewComments,
|
.filter((r) => r.body)
|
||||||
triggerTime,
|
.map((r) => ({
|
||||||
);
|
type: "review_body" as const,
|
||||||
|
id: r.databaseId,
|
||||||
|
pullNumber: prNumber,
|
||||||
|
body: r.body,
|
||||||
|
}));
|
||||||
|
|
||||||
const reviewComments: CommentWithImages[] = filteredReviewComments
|
const reviewComments: CommentWithImages[] = (reviewData?.nodes ?? [])
|
||||||
|
.flatMap((r) => r.comments?.nodes ?? [])
|
||||||
.filter((c) => c.body && !c.isMinimized)
|
.filter((c) => c.body && !c.isMinimized)
|
||||||
.map((c) => ({
|
.map((c) => ({
|
||||||
type: "review_comment" as const,
|
type: "review_comment" as const,
|
||||||
|
|||||||
@ -723,16 +723,17 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
triggerTime: "2024-01-15T12:00:00Z",
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reviewData field returns all reviews (not filtered), but the filtering
|
// Only the review submitted before the trigger and not edited afterward
|
||||||
// happens when processing review bodies for download
|
// reaches the prompt. The review submitted after the trigger and the one
|
||||||
// We can check the image download map to verify filtering
|
// edited after the trigger are dropped (TOCTOU protection), matching the
|
||||||
expect(result.reviewData?.nodes?.length).toBe(3); // All reviews are returned
|
// issue/PR comment and body handling.
|
||||||
|
expect(result.reviewData?.nodes?.length).toBe(1);
|
||||||
|
expect(result.reviewData?.nodes?.[0]?.databaseId).toBe("1");
|
||||||
|
|
||||||
// Check that only the first review's body would be downloaded (filtered)
|
// Only that surviving review's body is queued for image download.
|
||||||
const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) =>
|
const reviewsInMap = Object.keys(result.imageUrlMap).filter((key) =>
|
||||||
key.startsWith("review_body"),
|
key.startsWith("review_body"),
|
||||||
);
|
);
|
||||||
// Only review 1 should have its body processed (before trigger and not edited after)
|
|
||||||
expect(reviewsInMap.length).toBeLessThanOrEqual(1);
|
expect(reviewsInMap.length).toBeLessThanOrEqual(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -805,14 +806,83 @@ describe("fetchGitHubData integration with time filtering", () => {
|
|||||||
triggerTime: "2024-01-15T12:00:00Z",
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
});
|
});
|
||||||
|
|
||||||
// The imageUrlMap contains processed comments for image downloading
|
// The review itself is pre-trigger and kept, but its inline comments are
|
||||||
// We should have processed review comments, but only those before trigger time
|
// filtered to trigger time: the comment created after the trigger (id 11)
|
||||||
// The exact check depends on how imageUrlMap is structured, but we can verify
|
// and the one edited after the trigger (id 12) are dropped, leaving only
|
||||||
// that filtering occurred by checking the review data still has all nodes
|
// the pre-trigger comment (id 10).
|
||||||
expect(result.reviewData?.nodes?.length).toBe(1); // Original review is kept
|
expect(result.reviewData?.nodes?.length).toBe(1);
|
||||||
|
const reviewCommentIds =
|
||||||
|
result.reviewData?.nodes?.[0]?.comments?.nodes?.map((c) => c.databaseId);
|
||||||
|
expect(reviewCommentIds).toEqual(["10"]);
|
||||||
|
});
|
||||||
|
|
||||||
// The actual filtering happens during processing for image download
|
it("should filter reviews by both trigger time and actor", async () => {
|
||||||
// Since the mock doesn't actually download images, we verify the input was correct
|
const mockOctokits = {
|
||||||
|
graphql: jest.fn().mockResolvedValue({
|
||||||
|
repository: {
|
||||||
|
pullRequest: {
|
||||||
|
number: 321,
|
||||||
|
title: "Test PR",
|
||||||
|
body: "PR body",
|
||||||
|
author: { login: "author" },
|
||||||
|
comments: { nodes: [] },
|
||||||
|
files: { nodes: [] },
|
||||||
|
reviews: {
|
||||||
|
nodes: [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
databaseId: "1",
|
||||||
|
author: { login: "reviewer1" },
|
||||||
|
body: "Pre-trigger human review",
|
||||||
|
state: "APPROVED",
|
||||||
|
submittedAt: "2024-01-15T11:00:00Z",
|
||||||
|
comments: { nodes: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
databaseId: "2",
|
||||||
|
author: { login: "scanner[bot]" },
|
||||||
|
body: "Pre-trigger bot review",
|
||||||
|
state: "COMMENTED",
|
||||||
|
submittedAt: "2024-01-15T11:00:00Z",
|
||||||
|
comments: { nodes: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
databaseId: "3",
|
||||||
|
author: { login: "reviewer3" },
|
||||||
|
body: "Post-trigger human review",
|
||||||
|
state: "CHANGES_REQUESTED",
|
||||||
|
submittedAt: "2024-01-15T13:00:00Z",
|
||||||
|
comments: { nodes: [] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user: { login: "trigger-user" },
|
||||||
|
}),
|
||||||
|
rest: {
|
||||||
|
pulls: {
|
||||||
|
listFiles: jest.fn().mockResolvedValue({ data: [] }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await fetchGitHubData({
|
||||||
|
octokits: mockOctokits as any,
|
||||||
|
repository: "test-owner/test-repo",
|
||||||
|
prNumber: "321",
|
||||||
|
isPR: true,
|
||||||
|
triggerUsername: "trigger-user",
|
||||||
|
triggerTime: "2024-01-15T12:00:00Z",
|
||||||
|
excludeCommentsByActor: "*[bot]",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The trigger-time and actor filters compose: the pre-trigger human review
|
||||||
|
// is kept, the pre-trigger bot review is dropped by actor, and the
|
||||||
|
// post-trigger human review is dropped by trigger time.
|
||||||
|
expect(result.reviewData?.nodes?.map((r) => r.databaseId)).toEqual(["1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should handle backward compatibility when no trigger time provided", async () => {
|
it("should handle backward compatibility when no trigger time provided", async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user