Compare commits

..

4 Commits

Author SHA1 Message Date
Ashwin Bhat
6337623ebb fix: prevent TOCTOU race condition on issue/PR body edits (#710)
Add trigger-time validation for issue/PR body content to prevent attackers
from exploiting a race condition where they edit the body between when an
authorized user triggers @claude and when Claude processes the request.

The existing filterCommentsToTriggerTime() already protected comments -
this extends the same pattern to the main issue/PR body via isBodySafeToUse().

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-12-01 07:59:39 -08:00
GitHub Actions
6d79044f1d chore: bump Claude Code version to 2.0.55 2025-11-27 00:01:22 +00:00
Ashwin Bhat
a7e4c51380 fix: use cross-platform timeout for Claude Code installation (#700)
The GNU `timeout` command is not available on macOS. Check if it exists
and use it when available, otherwise run without timeout.

Also extracts the version into a CLAUDE_CODE_VERSION variable for
easier maintenance.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude <noreply@anthropic.com>
2025-11-24 20:57:33 -05:00
Ashwin Bhat
7febbb006b Remove experimental allowed domains feature (#697)
* chore: remove experimental allowed domains feature

Remove the experimental_allowed_domains feature which was used to
restrict network access via a Squid proxy. This removes:

- The input definition from action.yml
- The Network Restrictions workflow step
- The setup-network-restrictions.sh script
- Documentation from experimental.md, usage.md, and related files
- The input default from collect-inputs.ts

* chore: fix formatting with prettier

Co-authored-by: Ashwin Bhat <ashwin-ant@users.noreply.github.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Ashwin Bhat <ashwin-ant@users.noreply.github.com>
2025-11-24 19:03:53 -05:00
10 changed files with 318 additions and 236 deletions

View File

@@ -93,10 +93,6 @@ inputs:
description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events." description: "Force tag mode with tracking comments for pull_request and issue events. Only applicable to pull_request (opened, synchronize, ready_for_review, reopened) and issue (opened, edited, labeled, assigned) events."
required: false required: false
default: "false" default: "false"
experimental_allowed_domains:
description: "Restrict network access to these domains only (newline-separated). If not set, no restrictions are applied. Provider domains are auto-detected."
required: false
default: ""
path_to_claude_code_executable: path_to_claude_code_executable:
description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment." description: "Optional path to a custom Claude Code executable. If provided, skips automatic installation and uses this executable instead. WARNING: Using an older version may cause problems if the action begins taking advantage of new Claude Code features. This input is typically not needed unless you're debugging something specific or have unique needs in your environment."
required: false required: false
@@ -195,30 +191,23 @@ runs:
# Install Claude Code if no custom executable is provided # Install Claude Code if no custom executable is provided
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
CLAUDE_CODE_VERSION="2.0.50" CLAUDE_CODE_VERSION="2.0.55"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."
# Cross-platform timeout (GNU timeout not available on macOS) if command -v timeout &> /dev/null; then
(curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION") & timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
install_pid=$! else
( sleep 120; kill $install_pid 2>/dev/null ) & curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
timeout_pid=$!
if wait $install_pid 2>/dev/null; then
kill $timeout_pid 2>/dev/null || true
wait $timeout_pid 2>/dev/null || true
echo "Claude Code installed successfully"
break
fi fi
kill $timeout_pid 2>/dev/null || true
wait $timeout_pid 2>/dev/null || true
if [ $attempt -eq 3 ]; then if [ $attempt -eq 3 ]; then
echo "Failed to install Claude Code after 3 attempts" echo "Failed to install Claude Code after 3 attempts"
exit 1 exit 1
fi fi
echo "Installation timed out or failed, retrying..." echo "Installation failed, retrying..."
sleep 5 sleep 5
done done
echo "Claude Code installed successfully"
echo "$HOME/.local/bin" >> "$GITHUB_PATH" echo "$HOME/.local/bin" >> "$GITHUB_PATH"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
@@ -227,15 +216,6 @@ runs:
echo "$CLAUDE_DIR" >> "$GITHUB_PATH" echo "$CLAUDE_DIR" >> "$GITHUB_PATH"
fi fi
- name: Setup Network Restrictions
if: steps.prepare.outputs.contains_trigger == 'true' && inputs.experimental_allowed_domains != ''
shell: bash
run: |
chmod +x ${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
${GITHUB_ACTION_PATH}/scripts/setup-network-restrictions.sh
env:
EXPERIMENTAL_ALLOWED_DOMAINS: ${{ inputs.experimental_allowed_domains }}
- name: Run Claude Code - name: Run Claude Code
id: claude-code id: claude-code
if: steps.prepare.outputs.contains_trigger == 'true' if: steps.prepare.outputs.contains_trigger == 'true'

View File

@@ -117,30 +117,23 @@ runs:
shell: bash shell: bash
run: | run: |
if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then if [ -z "${{ inputs.path_to_claude_code_executable }}" ]; then
CLAUDE_CODE_VERSION="2.0.50" CLAUDE_CODE_VERSION="2.0.55"
echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..." echo "Installing Claude Code v${CLAUDE_CODE_VERSION}..."
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "Installation attempt $attempt..." echo "Installation attempt $attempt..."
# Cross-platform timeout (GNU timeout not available on macOS) if command -v timeout &> /dev/null; then
(curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION") & timeout 120 bash -c "curl -fsSL https://claude.ai/install.sh | bash -s -- $CLAUDE_CODE_VERSION" && break
install_pid=$! else
( sleep 120; kill $install_pid 2>/dev/null ) & curl -fsSL https://claude.ai/install.sh | bash -s -- "$CLAUDE_CODE_VERSION" && break
timeout_pid=$!
if wait $install_pid 2>/dev/null; then
kill $timeout_pid 2>/dev/null || true
wait $timeout_pid 2>/dev/null || true
echo "Claude Code installed successfully"
break
fi fi
kill $timeout_pid 2>/dev/null || true
wait $timeout_pid 2>/dev/null || true
if [ $attempt -eq 3 ]; then if [ $attempt -eq 3 ]; then
echo "Failed to install Claude Code after 3 attempts" echo "Failed to install Claude Code after 3 attempts"
exit 1 exit 1
fi fi
echo "Installation timed out or failed, retrying..." echo "Installation failed, retrying..."
sleep 5 sleep 5
done done
echo "Claude Code installed successfully"
else else
echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}" echo "Using custom Claude Code executable: ${{ inputs.path_to_claude_code_executable }}"
# Add the directory containing the custom executable to PATH # Add the directory containing the custom executable to PATH

View File

@@ -61,68 +61,3 @@ For specialized use cases, you can fine-tune behavior using `claude_args`:
--system-prompt "You are a code review specialist" --system-prompt "You are a code review specialist"
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }} anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
``` ```
## Network Restrictions
For enhanced security, you can restrict Claude's network access to specific domains only. This feature is particularly useful for:
- Enterprise environments with strict security policies
- Preventing access to external services
- Limiting Claude to only your internal APIs and services
When `experimental_allowed_domains` is set, Claude can only access the domains you explicitly list. You'll need to include the appropriate provider domains based on your authentication method.
### Provider-Specific Examples
#### If using Anthropic API or subscription
```yaml
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Or: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
experimental_allowed_domains: |
.anthropic.com
```
#### If using AWS Bedrock
```yaml
- uses: anthropics/claude-code-action@v1
with:
use_bedrock: "true"
experimental_allowed_domains: |
bedrock.*.amazonaws.com
bedrock-runtime.*.amazonaws.com
```
#### If using Google Vertex AI
```yaml
- uses: anthropics/claude-code-action@v1
with:
use_vertex: "true"
experimental_allowed_domains: |
*.googleapis.com
vertexai.googleapis.com
```
### Common GitHub Domains
In addition to your provider domains, you may need to include GitHub-related domains. For GitHub.com users, common domains include:
```yaml
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
experimental_allowed_domains: |
.anthropic.com # For Anthropic API
.github.com
.githubusercontent.com
ghcr.io
.blob.core.windows.net
```
For GitHub Enterprise users, replace the GitHub.com domains above with your enterprise domains (e.g., `.github.company.com`, `packages.company.com`, etc.).
To determine which domains your workflow needs, you can temporarily run without restrictions and monitor the network requests, or check your GitHub Enterprise configuration for the specific services you use.

View File

@@ -70,7 +70,6 @@ jobs:
| `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` | | `branch_prefix` | The prefix to use for Claude branches (defaults to 'claude/', use 'claude-' for dash format) | No | `claude/` |
| `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" | | `settings` | Claude Code settings as JSON string or path to settings JSON file | No | "" |
| `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" | | `additional_permissions` | Additional permissions to enable. Currently supports 'actions: read' for viewing workflow results | No | "" |
| `experimental_allowed_domains` | Restrict network access to these domains only (newline-separated). | No | "" |
| `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` | | `use_commit_signing` | Enable commit signing using GitHub's commit signature verification. When false, Claude uses standard git commands | No | `false` |
| `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` | | `bot_id` | GitHub user ID to use for git operations (defaults to Claude's bot ID) | No | `41898282` |
| `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` | | `bot_name` | GitHub username to use for git operations (defaults to Claude's bot name) | No | `claude[bot]` |

View File

@@ -1,123 +0,0 @@
#!/bin/bash
# Setup Network Restrictions with Squid Proxy
# This script sets up a Squid proxy to restrict network access to whitelisted domains only.
set -e
# Check if experimental_allowed_domains is provided
if [ -z "$EXPERIMENTAL_ALLOWED_DOMAINS" ]; then
echo "ERROR: EXPERIMENTAL_ALLOWED_DOMAINS environment variable is required"
exit 1
fi
# Check required environment variables
if [ -z "$RUNNER_TEMP" ]; then
echo "ERROR: RUNNER_TEMP environment variable is required"
exit 1
fi
if [ -z "$GITHUB_ENV" ]; then
echo "ERROR: GITHUB_ENV environment variable is required"
exit 1
fi
echo "Setting up network restrictions with Squid proxy..."
SQUID_START_TIME=$(date +%s.%N)
# Create whitelist file
echo "$EXPERIMENTAL_ALLOWED_DOMAINS" > $RUNNER_TEMP/whitelist.txt
# Ensure each domain has proper format
# If domain doesn't start with a dot and isn't an IP, add the dot for subdomain matching
mv $RUNNER_TEMP/whitelist.txt $RUNNER_TEMP/whitelist.txt.orig
while IFS= read -r domain; do
if [ -n "$domain" ]; then
# Trim whitespace
domain=$(echo "$domain" | xargs)
# If it's not empty and doesn't start with a dot, add one
if [[ "$domain" != .* ]] && [[ ! "$domain" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo ".$domain" >> $RUNNER_TEMP/whitelist.txt
else
echo "$domain" >> $RUNNER_TEMP/whitelist.txt
fi
fi
done < $RUNNER_TEMP/whitelist.txt.orig
# Create Squid config with whitelist
echo "http_port 3128" > $RUNNER_TEMP/squid.conf
echo "" >> $RUNNER_TEMP/squid.conf
echo "# Define ACLs" >> $RUNNER_TEMP/squid.conf
echo "acl whitelist dstdomain \"/etc/squid/whitelist.txt\"" >> $RUNNER_TEMP/squid.conf
echo "acl localnet src 127.0.0.1/32" >> $RUNNER_TEMP/squid.conf
echo "acl localnet src 172.17.0.0/16" >> $RUNNER_TEMP/squid.conf
echo "acl SSL_ports port 443" >> $RUNNER_TEMP/squid.conf
echo "acl Safe_ports port 80" >> $RUNNER_TEMP/squid.conf
echo "acl Safe_ports port 443" >> $RUNNER_TEMP/squid.conf
echo "acl CONNECT method CONNECT" >> $RUNNER_TEMP/squid.conf
echo "" >> $RUNNER_TEMP/squid.conf
echo "# Deny requests to certain unsafe ports" >> $RUNNER_TEMP/squid.conf
echo "http_access deny !Safe_ports" >> $RUNNER_TEMP/squid.conf
echo "" >> $RUNNER_TEMP/squid.conf
echo "# Only allow CONNECT to SSL ports" >> $RUNNER_TEMP/squid.conf
echo "http_access deny CONNECT !SSL_ports" >> $RUNNER_TEMP/squid.conf
echo "" >> $RUNNER_TEMP/squid.conf
echo "# Allow localhost" >> $RUNNER_TEMP/squid.conf
echo "http_access allow localhost" >> $RUNNER_TEMP/squid.conf
echo "" >> $RUNNER_TEMP/squid.conf
echo "# Allow localnet access to whitelisted domains" >> $RUNNER_TEMP/squid.conf
echo "http_access allow localnet whitelist" >> $RUNNER_TEMP/squid.conf
echo "" >> $RUNNER_TEMP/squid.conf
echo "# Deny everything else" >> $RUNNER_TEMP/squid.conf
echo "http_access deny all" >> $RUNNER_TEMP/squid.conf
echo "Starting Squid proxy..."
# First, remove any existing container
sudo docker rm -f squid-proxy 2>/dev/null || true
# Ensure whitelist file is not empty (Squid fails with empty files)
if [ ! -s "$RUNNER_TEMP/whitelist.txt" ]; then
echo "WARNING: Whitelist file is empty, adding a dummy entry"
echo ".example.com" >> $RUNNER_TEMP/whitelist.txt
fi
# Use sudo to prevent Claude from stopping the container
CONTAINER_ID=$(sudo docker run -d \
--name squid-proxy \
-p 127.0.0.1:3128:3128 \
-v $RUNNER_TEMP/squid.conf:/etc/squid/squid.conf:ro \
-v $RUNNER_TEMP/whitelist.txt:/etc/squid/whitelist.txt:ro \
ubuntu/squid:latest 2>&1) || {
echo "ERROR: Failed to start Squid container"
exit 1
}
# Wait for proxy to be ready (usually < 1 second)
READY=false
for i in {1..30}; do
if nc -z 127.0.0.1 3128 2>/dev/null; then
TOTAL_TIME=$(echo "scale=3; $(date +%s.%N) - $SQUID_START_TIME" | bc)
echo "Squid proxy ready in ${TOTAL_TIME}s"
READY=true
break
fi
sleep 0.1
done
if [ "$READY" != "true" ]; then
echo "ERROR: Squid proxy failed to start within 3 seconds"
echo "Container logs:"
sudo docker logs squid-proxy 2>&1 || true
echo "Container status:"
sudo docker ps -a | grep squid-proxy || true
exit 1
fi
# Set proxy environment variables
echo "http_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
echo "https_proxy=http://127.0.0.1:3128" >> $GITHUB_ENV
echo "HTTP_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
echo "HTTPS_PROXY=http://127.0.0.1:3128" >> $GITHUB_ENV
echo "Network restrictions setup completed successfully"

View File

@@ -26,7 +26,6 @@ export function collectActionInputsPresence(): void {
max_turns: "", max_turns: "",
use_sticky_comment: "false", use_sticky_comment: "false",
use_commit_signing: "false", use_commit_signing: "false",
experimental_allowed_domains: "",
}; };
const allInputsJson = process.env.ALL_INPUTS; const allInputsJson = process.env.ALL_INPUTS;

View File

@@ -13,6 +13,8 @@ export const PR_QUERY = `
headRefName headRefName
headRefOid headRefOid
createdAt createdAt
updatedAt
lastEditedAt
additions additions
deletions deletions
state state
@@ -96,6 +98,8 @@ export const ISSUE_QUERY = `
login login
} }
createdAt createdAt
updatedAt
lastEditedAt
state state
comments(first: 100) { comments(first: 100) {
nodes { nodes {

View File

@@ -107,6 +107,38 @@ export function filterReviewsToTriggerTime<
}); });
} }
/**
* Checks if the issue/PR body was edited after the trigger time.
* This prevents a race condition where an attacker could edit the issue/PR body
* between when an authorized user triggered Claude and when Claude processes the request.
*
* @param contextData - The PR or issue data containing body and edit timestamps
* @param triggerTime - ISO timestamp of when the trigger event occurred
* @returns true if the body is safe to use, false if it was edited after trigger
*/
export function isBodySafeToUse(
contextData: { createdAt: string; updatedAt?: string; lastEditedAt?: string },
triggerTime: string | undefined,
): boolean {
// If no trigger time is available, we can't validate - allow the body
// This maintains backwards compatibility for triggers that don't have timestamps
if (!triggerTime) return true;
const triggerTimestamp = new Date(triggerTime).getTime();
// Check if the body was edited after the trigger
// Use lastEditedAt if available (more accurate for body edits), otherwise fall back to updatedAt
const lastEditTime = contextData.lastEditedAt || contextData.updatedAt;
if (lastEditTime) {
const lastEditTimestamp = new Date(lastEditTime).getTime();
if (lastEditTimestamp >= triggerTimestamp) {
return false;
}
}
return true;
}
type FetchDataParams = { type FetchDataParams = {
octokits: Octokits; octokits: Octokits;
repository: string; repository: string;
@@ -273,9 +305,13 @@ export async function fetchGitHubData({
body: c.body, body: c.body,
})); }));
// Add the main issue/PR body if it has content // Add the main issue/PR body if it has content and wasn't edited after trigger
const mainBody: CommentWithImages[] = contextData.body // This prevents a TOCTOU race condition where an attacker could edit the body
? [ // between when an authorized user triggered Claude and when Claude processes the request
let mainBody: CommentWithImages[] = [];
if (contextData.body) {
if (isBodySafeToUse(contextData, triggerTime)) {
mainBody = [
{ {
...(isPR ...(isPR
? { ? {
@@ -289,8 +325,14 @@ export async function fetchGitHubData({
body: contextData.body, body: contextData.body,
}), }),
}, },
] ];
: []; } else {
console.warn(
`Security: ${isPR ? "PR" : "Issue"} #${prNumber} body was edited after the trigger event. ` +
`Excluding body content to prevent potential injection attacks.`,
);
}
}
const allComments = [ const allComments = [
...mainBody, ...mainBody,

View File

@@ -58,6 +58,8 @@ export type GitHubPullRequest = {
headRefName: string; headRefName: string;
headRefOid: string; headRefOid: string;
createdAt: string; createdAt: string;
updatedAt?: string;
lastEditedAt?: string;
additions: number; additions: number;
deletions: number; deletions: number;
state: string; state: string;
@@ -83,6 +85,8 @@ export type GitHubIssue = {
body: string; body: string;
author: GitHubAuthor; author: GitHubAuthor;
createdAt: string; createdAt: string;
updatedAt?: string;
lastEditedAt?: string;
state: string; state: string;
comments: { comments: {
nodes: GitHubComment[]; nodes: GitHubComment[];

View File

@@ -4,6 +4,7 @@ import {
fetchGitHubData, fetchGitHubData,
filterCommentsToTriggerTime, filterCommentsToTriggerTime,
filterReviewsToTriggerTime, filterReviewsToTriggerTime,
isBodySafeToUse,
} from "../src/github/data/fetcher"; } from "../src/github/data/fetcher";
import { import {
createMockContext, createMockContext,
@@ -371,6 +372,139 @@ describe("filterReviewsToTriggerTime", () => {
}); });
}); });
describe("isBodySafeToUse", () => {
const triggerTime = "2024-01-15T12:00:00Z";
const createMockContextData = (
createdAt: string,
updatedAt?: string,
lastEditedAt?: string,
) => ({
createdAt,
updatedAt,
lastEditedAt,
});
describe("body edit time validation", () => {
it("should return true when body was never edited", () => {
const contextData = createMockContextData("2024-01-15T10:00:00Z");
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
});
it("should return true when body was edited before trigger time", () => {
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T11:00:00Z",
"2024-01-15T11:30:00Z",
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
});
it("should return false when body was edited after trigger time (using updatedAt)", () => {
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T13:00:00Z",
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
});
it("should return false when body was edited after trigger time (using lastEditedAt)", () => {
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
undefined,
"2024-01-15T13:00:00Z",
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
});
it("should return false when body was edited exactly at trigger time", () => {
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T12:00:00Z",
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
});
it("should prioritize lastEditedAt over updatedAt", () => {
// updatedAt is after trigger, but lastEditedAt is before - should be safe
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T13:00:00Z", // updatedAt after trigger
"2024-01-15T11:00:00Z", // lastEditedAt before trigger
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
});
});
describe("edge cases", () => {
it("should return true when no trigger time is provided (backward compatibility)", () => {
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T13:00:00Z", // Would normally fail
"2024-01-15T14:00:00Z", // Would normally fail
);
expect(isBodySafeToUse(contextData, undefined)).toBe(true);
});
it("should handle millisecond precision correctly", () => {
// Edit 1ms after trigger - should be unsafe
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T12:00:00.001Z",
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(false);
});
it("should handle edit 1ms before trigger - should be safe", () => {
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T11:59:59.999Z",
);
expect(isBodySafeToUse(contextData, triggerTime)).toBe(true);
});
it("should handle various ISO timestamp formats", () => {
const contextData1 = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T11:00:00Z",
);
const contextData2 = createMockContextData(
"2024-01-15T10:00:00+00:00",
"2024-01-15T11:00:00+00:00",
);
const contextData3 = createMockContextData(
"2024-01-15T10:00:00.000Z",
"2024-01-15T11:00:00.000Z",
);
expect(isBodySafeToUse(contextData1, triggerTime)).toBe(true);
expect(isBodySafeToUse(contextData2, triggerTime)).toBe(true);
expect(isBodySafeToUse(contextData3, triggerTime)).toBe(true);
});
});
describe("security scenarios", () => {
it("should detect race condition attack - body edited between trigger and processing", () => {
// Simulates: Owner triggers @claude at 12:00, attacker edits body at 12:00:30
const contextData = createMockContextData(
"2024-01-15T10:00:00Z", // Issue created
"2024-01-15T12:00:30Z", // Body edited after trigger
);
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(false);
});
it("should allow body that was stable at trigger time", () => {
// Body was last edited well before the trigger
const contextData = createMockContextData(
"2024-01-15T10:00:00Z",
"2024-01-15T10:30:00Z",
"2024-01-15T10:30:00Z",
);
expect(isBodySafeToUse(contextData, "2024-01-15T12:00:00Z")).toBe(true);
});
});
});
describe("fetchGitHubData integration with time filtering", () => { describe("fetchGitHubData integration with time filtering", () => {
it("should filter comments based on trigger time when provided", async () => { it("should filter comments based on trigger time when provided", async () => {
const mockOctokits = { const mockOctokits = {
@@ -696,4 +830,119 @@ describe("fetchGitHubData integration with time filtering", () => {
// All three comments should be included as they're all before trigger time // All three comments should be included as they're all before trigger time
expect(result.comments.length).toBe(3); expect(result.comments.length).toBe(3);
}); });
it("should exclude issue body when edited after trigger time (TOCTOU protection)", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
issue: {
number: 555,
title: "Test Issue",
body: "Malicious body edited after trigger",
author: { login: "attacker" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
comments: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "555",
isPR: false,
triggerUsername: "trigger-user",
triggerTime: "2024-01-15T12:00:00Z",
});
// The body should be excluded from image processing due to TOCTOU protection
// We can verify this by checking that issue_body is NOT in the imageUrlMap keys
const hasIssueBodyInMap = Array.from(result.imageUrlMap.keys()).some(
(key) => key.includes("issue_body"),
);
expect(hasIssueBodyInMap).toBe(false);
});
it("should include issue body when not edited after trigger time", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
issue: {
number: 666,
title: "Test Issue",
body: "Safe body not edited after trigger",
author: { login: "author" },
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T11:00:00Z", // Edited before trigger
lastEditedAt: "2024-01-15T11:00:00Z", // Edited before trigger
comments: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "666",
isPR: false,
triggerUsername: "trigger-user",
triggerTime: "2024-01-15T12:00:00Z",
});
// The contextData should still contain the body
expect(result.contextData.body).toBe("Safe body not edited after trigger");
});
it("should exclude PR body when edited after trigger time (TOCTOU protection)", async () => {
const mockOctokits = {
graphql: jest.fn().mockResolvedValue({
repository: {
pullRequest: {
number: 777,
title: "Test PR",
body: "Malicious PR body edited after trigger",
author: { login: "attacker" },
baseRefName: "main",
headRefName: "feature",
headRefOid: "abc123",
createdAt: "2024-01-15T10:00:00Z",
updatedAt: "2024-01-15T12:30:00Z", // Edited after trigger
lastEditedAt: "2024-01-15T12:30:00Z", // Edited after trigger
additions: 10,
deletions: 5,
state: "OPEN",
commits: { totalCount: 1, nodes: [] },
files: { nodes: [] },
comments: { nodes: [] },
reviews: { nodes: [] },
},
},
user: { login: "trigger-user" },
}),
rest: jest.fn() as any,
};
const result = await fetchGitHubData({
octokits: mockOctokits as any,
repository: "test-owner/test-repo",
prNumber: "777",
isPR: true,
triggerUsername: "trigger-user",
triggerTime: "2024-01-15T12:00:00Z",
});
// The body should be excluded from image processing due to TOCTOU protection
const hasPrBodyInMap = Array.from(result.imageUrlMap.keys()).some((key) =>
key.includes("pr_body"),
);
expect(hasPrBodyInMap).toBe(false);
});
}); });