mirror of
https://github.com/anthropics/claude-code-action.git
synced 2026-01-22 22:44:13 +08:00
feat: add time-based comment filtering to tag mode (#512)
Implement time-based filtering for GitHub comments and reviews to prevent malicious actors from editing existing comments after Claude is triggered to inject harmful content. Changes: - Add updatedAt and lastEditedAt fields to GraphQL queries - Update GitHubComment and GitHubReview types with timestamp fields - Implement filterCommentsToTriggerTime() and filterReviewsToTriggerTime() - Add extractTriggerTimestamp() to extract trigger time from webhooks - Update tag and review modes to pass trigger timestamp to data fetcher Security benefits: - Prevents comment injection attacks via post-trigger edits - Maintains chronological integrity of conversation context - Ensures only comments in their final state before trigger are processed - Backward compatible with graceful degradation 🤖 Generated with [Claude Code](https://claude.ai/code) Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,12 @@
|
||||
import { execFileSync } from "child_process";
|
||||
import type { Octokits } from "../api/client";
|
||||
import { ISSUE_QUERY, PR_QUERY, USER_QUERY } from "../api/queries/github";
|
||||
import {
|
||||
isIssueCommentEvent,
|
||||
isPullRequestReviewEvent,
|
||||
isPullRequestReviewCommentEvent,
|
||||
type ParsedGitHubContext,
|
||||
} from "../context";
|
||||
import type {
|
||||
GitHubComment,
|
||||
GitHubFile,
|
||||
@@ -13,12 +19,101 @@ import type {
|
||||
import type { CommentWithImages } from "../utils/image-downloader";
|
||||
import { downloadCommentImages } from "../utils/image-downloader";
|
||||
|
||||
/**
|
||||
* Extracts the trigger timestamp from the GitHub webhook payload.
|
||||
* This timestamp represents when the triggering comment/review/event was created.
|
||||
*
|
||||
* @param context - Parsed GitHub context from webhook
|
||||
* @returns ISO timestamp string or undefined if not available
|
||||
*/
|
||||
export function extractTriggerTimestamp(
|
||||
context: ParsedGitHubContext,
|
||||
): string | undefined {
|
||||
if (isIssueCommentEvent(context)) {
|
||||
return context.payload.comment.created_at || undefined;
|
||||
} else if (isPullRequestReviewEvent(context)) {
|
||||
return context.payload.review.submitted_at || undefined;
|
||||
} else if (isPullRequestReviewCommentEvent(context)) {
|
||||
return context.payload.comment.created_at || undefined;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters comments to only include those that existed in their final state before the trigger time.
|
||||
* This prevents malicious actors from editing comments after the trigger to inject harmful content.
|
||||
*
|
||||
* @param comments - Array of GitHub comments to filter
|
||||
* @param triggerTime - ISO timestamp of when the trigger comment was created
|
||||
* @returns Filtered array of comments that were created and last edited before trigger time
|
||||
*/
|
||||
export function filterCommentsToTriggerTime<
|
||||
T extends { createdAt: string; updatedAt?: string; lastEditedAt?: string },
|
||||
>(comments: T[], triggerTime: string | undefined): T[] {
|
||||
if (!triggerTime) return comments;
|
||||
|
||||
const triggerTimestamp = new Date(triggerTime).getTime();
|
||||
|
||||
return comments.filter((comment) => {
|
||||
// Comment must have been created before trigger (not at or after)
|
||||
const createdTimestamp = new Date(comment.createdAt).getTime();
|
||||
if (createdTimestamp >= triggerTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If comment has been edited, the most recent edit must have occurred before trigger
|
||||
// Use lastEditedAt if available, otherwise fall back to updatedAt
|
||||
const lastEditTime = comment.lastEditedAt || comment.updatedAt;
|
||||
if (lastEditTime) {
|
||||
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
||||
if (lastEditTimestamp >= triggerTimestamp) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters reviews to only include those that existed in their final state before the trigger time.
|
||||
* Similar to filterCommentsToTriggerTime but for GitHubReview objects which use submittedAt instead of createdAt.
|
||||
*/
|
||||
export function filterReviewsToTriggerTime<
|
||||
T extends { submittedAt: string; updatedAt?: string; lastEditedAt?: string },
|
||||
>(reviews: T[], triggerTime: string | undefined): T[] {
|
||||
if (!triggerTime) return reviews;
|
||||
|
||||
const triggerTimestamp = new Date(triggerTime).getTime();
|
||||
|
||||
return reviews.filter((review) => {
|
||||
// Review must have been submitted before trigger (not at or after)
|
||||
const submittedTimestamp = new Date(review.submittedAt).getTime();
|
||||
if (submittedTimestamp >= triggerTimestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If review has been edited, the most recent edit must have occurred before trigger
|
||||
const lastEditTime = review.lastEditedAt || review.updatedAt;
|
||||
if (lastEditTime) {
|
||||
const lastEditTimestamp = new Date(lastEditTime).getTime();
|
||||
if (lastEditTimestamp >= triggerTimestamp) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
type FetchDataParams = {
|
||||
octokits: Octokits;
|
||||
repository: string;
|
||||
prNumber: string;
|
||||
isPR: boolean;
|
||||
triggerUsername?: string;
|
||||
triggerTime?: string;
|
||||
};
|
||||
|
||||
export type GitHubFileWithSHA = GitHubFile & {
|
||||
@@ -41,6 +136,7 @@ export async function fetchGitHubData({
|
||||
prNumber,
|
||||
isPR,
|
||||
triggerUsername,
|
||||
triggerTime,
|
||||
}: FetchDataParams): Promise<FetchDataResult> {
|
||||
const [owner, repo] = repository.split("/");
|
||||
if (!owner || !repo) {
|
||||
@@ -68,7 +164,10 @@ export async function fetchGitHubData({
|
||||
const pullRequest = prResult.repository.pullRequest;
|
||||
contextData = pullRequest;
|
||||
changedFiles = pullRequest.files.nodes || [];
|
||||
comments = pullRequest.comments?.nodes || [];
|
||||
comments = filterCommentsToTriggerTime(
|
||||
pullRequest.comments?.nodes || [],
|
||||
triggerTime,
|
||||
);
|
||||
reviewData = pullRequest.reviews || [];
|
||||
|
||||
console.log(`Successfully fetched PR #${prNumber} data`);
|
||||
@@ -88,7 +187,10 @@ export async function fetchGitHubData({
|
||||
|
||||
if (issueResult.repository.issue) {
|
||||
contextData = issueResult.repository.issue;
|
||||
comments = contextData?.comments?.nodes || [];
|
||||
comments = filterCommentsToTriggerTime(
|
||||
contextData?.comments?.nodes || [],
|
||||
triggerTime,
|
||||
);
|
||||
|
||||
console.log(`Successfully fetched issue #${prNumber} data`);
|
||||
} else {
|
||||
@@ -141,25 +243,35 @@ export async function fetchGitHubData({
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
const reviewBodies: CommentWithImages[] =
|
||||
reviewData?.nodes
|
||||
?.filter((r) => r.body)
|
||||
.map((r) => ({
|
||||
type: "review_body" as const,
|
||||
id: r.databaseId,
|
||||
pullNumber: prNumber,
|
||||
body: r.body,
|
||||
})) ?? [];
|
||||
// Filter review bodies to trigger time
|
||||
const filteredReviewBodies = reviewData?.nodes
|
||||
? filterReviewsToTriggerTime(reviewData.nodes, triggerTime).filter(
|
||||
(r) => r.body,
|
||||
)
|
||||
: [];
|
||||
|
||||
const reviewComments: CommentWithImages[] =
|
||||
reviewData?.nodes
|
||||
?.flatMap((r) => r.comments?.nodes ?? [])
|
||||
.filter((c) => c.body && !c.isMinimized)
|
||||
.map((c) => ({
|
||||
type: "review_comment" as const,
|
||||
id: c.databaseId,
|
||||
body: c.body,
|
||||
})) ?? [];
|
||||
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
|
||||
const allReviewComments =
|
||||
reviewData?.nodes?.flatMap((r) => r.comments?.nodes ?? []) ?? [];
|
||||
const filteredReviewComments = filterCommentsToTriggerTime(
|
||||
allReviewComments,
|
||||
triggerTime,
|
||||
);
|
||||
|
||||
const reviewComments: CommentWithImages[] = filteredReviewComments
|
||||
.filter((c) => c.body && !c.isMinimized)
|
||||
.map((c) => ({
|
||||
type: "review_comment" as const,
|
||||
id: c.databaseId,
|
||||
body: c.body,
|
||||
}));
|
||||
|
||||
// Add the main issue/PR body if it has content
|
||||
const mainBody: CommentWithImages[] = contextData.body
|
||||
|
||||
Reference in New Issue
Block a user