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
55 changes: 47 additions & 8 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,23 @@ inputs:
default: '20'

claude-api-key:
description: 'Anthropic Claude API key for security analysis'
required: true
description: 'Anthropic Claude API key for security analysis. Required unless `use-vertex` is true.'
required: false
default: ''

use-vertex:
description: 'Route Claude calls through Google Vertex AI instead of api.anthropic.com. When true, the caller must have already authenticated to GCP (e.g. via google-github-actions/auth) before invoking this action.'
required: false
default: 'false'

vertex-region:
description: 'GCP region for Vertex AI (e.g. global, us-east5). Only used when use-vertex is true.'
required: false
default: 'global'

vertex-project-id:
description: 'GCP project ID hosting Vertex AI. Required when use-vertex is true.'
required: false
default: ''

claude-model:
Expand Down Expand Up @@ -182,7 +197,13 @@ runs:
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ANTHROPIC_API_KEY: ${{ inputs.claude-api-key }}
ENABLE_CLAUDE_FILTERING: 'true'
# Vertex AI passthrough — claude CLI subprocess and Python ClaudeAPIClient
# both honor these. CLAUDE_CODE_USE_VERTEX is the canonical flag the
# `claude` CLI checks; the Python side reads it too.
CLAUDE_CODE_USE_VERTEX: ${{ inputs.use-vertex == 'true' && '1' || '' }}
ANTHROPIC_VERTEX_PROJECT_ID: ${{ inputs.vertex-project-id }}
CLOUD_ML_REGION: ${{ inputs.vertex-region }}
ENABLE_CLAUDE_FILTERING: 'true'
EXCLUDE_DIRECTORIES: ${{ inputs.exclude-directories }}
FALSE_POSITIVE_FILTERING_INSTRUCTIONS: ${{ inputs.false-positive-filtering-instructions }}
CUSTOM_SECURITY_SCAN_INSTRUCTIONS: ${{ inputs.custom-security-scan-instructions }}
Expand All @@ -203,13 +224,30 @@ runs:
exit 0
fi

# Validate API key is provided
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "::error::ANTHROPIC_API_KEY is not set. Please provide the claude-api-key input to the action."
echo "Example usage:"
echo " - uses: anthropics/claude-code-security-reviewer@main"
# Validate auth: either Anthropic API key, or Vertex AI configured.
if [ "$CLAUDE_CODE_USE_VERTEX" = "1" ]; then
if [ -z "$ANTHROPIC_VERTEX_PROJECT_ID" ]; then
echo "::error::use-vertex is true but vertex-project-id was not provided."
exit 1
fi
if [ -z "$GOOGLE_APPLICATION_CREDENTIALS" ] && [ -z "$GOOGLE_GHA_CREDS_PATH" ]; then
echo "::warning::use-vertex is true but no GOOGLE_APPLICATION_CREDENTIALS detected. Did you run google-github-actions/auth before this step?"
fi
elif [ -z "$ANTHROPIC_API_KEY" ]; then
echo "::error::Neither claude-api-key nor use-vertex was provided."
echo "Example (direct API):"
echo " - uses: anthropics/claude-code-security-review@main"
echo " with:"
echo " claude-api-key: \$\{{ secrets.ANTHROPIC_API_KEY }}"
echo "Example (Vertex AI):"
echo " - uses: google-github-actions/auth@v2"
echo " with:"
echo " workload_identity_provider: ..."
echo " service_account: ..."
echo " - uses: anthropics/claude-code-security-review@main"
echo " with:"
echo " use-vertex: 'true'"
echo " vertex-project-id: my-gcp-project"
exit 1
fi

Expand All @@ -226,6 +264,7 @@ runs:
echo "Python version: $(python --version)"
echo "Claude CLI version: $(claude --version 2>&1 || echo 'Claude CLI not found')"
echo "ANTHROPIC_API_KEY set: $(if [ -n "$ANTHROPIC_API_KEY" ]; then echo 'Yes'; else echo 'No'; fi)"
echo "Vertex AI mode: $(if [ "$CLAUDE_CODE_USE_VERTEX" = "1" ]; then echo "Yes (project=$ANTHROPIC_VERTEX_PROJECT_ID, region=$CLOUD_ML_REGION)"; else echo 'No'; fi)"
echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY"
echo "PR_NUMBER: $PR_NUMBER"
echo "Python path: $PYTHONPATH"
Expand Down
87 changes: 65 additions & 22 deletions claudecode/claude_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from typing import Dict, Any, Tuple, Optional
from pathlib import Path

from anthropic import Anthropic
from anthropic import Anthropic, AnthropicVertex

from claudecode.constants import (
DEFAULT_CLAUDE_MODEL, DEFAULT_TIMEOUT_SECONDS, DEFAULT_MAX_RETRIES,
Expand All @@ -20,42 +20,76 @@

class ClaudeAPIClient:
"""Client for calling Claude API directly for security analysis tasks."""
def __init__(self,

def __init__(self,
model: Optional[str] = None,
api_key: Optional[str] = None,
timeout_seconds: Optional[int] = None,
max_retries: Optional[int] = None):
max_retries: Optional[int] = None,
use_vertex: bool = False,
vertex_region: Optional[str] = None,
vertex_project_id: Optional[str] = None):
"""Initialize Claude API client.

Args:
model: Claude model to use
api_key: Anthropic API key (if None, reads from ANTHROPIC_API_KEY env var)
api_key: Anthropic API key (only used when use_vertex is False;
if None, reads from ANTHROPIC_API_KEY env var)
timeout_seconds: Request timeout in seconds
max_retries: Maximum retry attempts for API calls
use_vertex: When True, route requests through Vertex AI instead
of api.anthropic.com. Auth is taken from Application Default
Credentials, so callers must set up GOOGLE_APPLICATION_CREDENTIALS
(e.g. via google-github-actions/auth) before instantiating.
vertex_region: GCP region for Vertex AI (e.g. "global", "us-east5").
Defaults to CLOUD_ML_REGION env var or "global".
vertex_project_id: GCP project ID hosting Vertex AI. Defaults to
ANTHROPIC_VERTEX_PROJECT_ID env var.
"""
self.model = model or DEFAULT_CLAUDE_MODEL
self.timeout_seconds = timeout_seconds or DEFAULT_TIMEOUT_SECONDS
self.max_retries = max_retries or DEFAULT_MAX_RETRIES

# Get API key from environment or parameter
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
if not self.api_key:
raise ValueError(
"No Anthropic API key found. Please set ANTHROPIC_API_KEY environment variable "
"or provide api_key parameter."
self.use_vertex = use_vertex

if use_vertex:
self.api_key = None
region = vertex_region or os.environ.get("CLOUD_ML_REGION") or "global"
project_id = vertex_project_id or os.environ.get("ANTHROPIC_VERTEX_PROJECT_ID")
if not project_id:
raise ValueError(
"Vertex AI mode requires a project ID. Provide vertex_project_id "
"or set ANTHROPIC_VERTEX_PROJECT_ID environment variable."
)
self.client = AnthropicVertex(region=region, project_id=project_id)
logger.info(
f"Claude API client initialized via Vertex AI "
f"(region={region}, project={project_id})"
)

# Initialize Anthropic client
self.client = Anthropic(api_key=self.api_key)
logger.info("Claude API client initialized successfully")
else:
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
if not self.api_key:
raise ValueError(
"No Anthropic API key found. Please set ANTHROPIC_API_KEY environment variable "
"or provide api_key parameter."
)
self.client = Anthropic(api_key=self.api_key)
logger.info("Claude API client initialized successfully")

def validate_api_access(self) -> Tuple[bool, str]:
"""Validate that API access is working.

Returns:
Tuple of (success, error_message)
"""
# Vertex mode: skip the proactive validation call. The AnthropicVertex
# client uses Application Default Credentials and we can't cheaply
# probe a model that's guaranteed to be enabled in the caller's
# project — any real auth/quota issue will surface on the first
# actual filter call, which already has retry and error handling.
if self.use_vertex:
logger.info("Claude API access validation skipped (Vertex mode)")
return True, ""

try:
# Simple test call to verify API access
self.client.messages.create(
Expand Down Expand Up @@ -356,21 +390,30 @@ def _read_file(self, file_path: str) -> Tuple[bool, str, str]:

def get_claude_api_client(model: str = DEFAULT_CLAUDE_MODEL,
api_key: Optional[str] = None,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS) -> ClaudeAPIClient:
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
use_vertex: bool = False,
vertex_region: Optional[str] = None,
vertex_project_id: Optional[str] = None) -> ClaudeAPIClient:
"""Convenience function to get Claude API client.

Args:
model: Claude model identifier
api_key: Optional API key (reads from environment if not provided)
timeout_seconds: API call timeout

use_vertex: Route through Vertex AI instead of api.anthropic.com
vertex_region: GCP region for Vertex AI
vertex_project_id: GCP project hosting Vertex AI

Returns:
Initialized ClaudeAPIClient instance
"""
return ClaudeAPIClient(
model=model,
api_key=api_key,
timeout_seconds=timeout_seconds
timeout_seconds=timeout_seconds,
use_vertex=use_vertex,
vertex_region=vertex_region,
vertex_project_id=vertex_project_id,
)


21 changes: 15 additions & 6 deletions claudecode/findings_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,32 +157,41 @@ def get_exclusion_reason(cls, finding: Dict[str, Any]) -> Optional[str]:
class FindingsFilter:
"""Main filter class for security findings."""

def __init__(self,
def __init__(self,
use_hard_exclusions: bool = True,
use_claude_filtering: bool = True,
api_key: Optional[str] = None,
model: str = DEFAULT_CLAUDE_MODEL,
custom_filtering_instructions: Optional[str] = None):
custom_filtering_instructions: Optional[str] = None,
use_vertex: bool = False,
vertex_region: Optional[str] = None,
vertex_project_id: Optional[str] = None):
"""Initialize findings filter.

Args:
use_hard_exclusions: Whether to apply hard exclusion rules
use_claude_filtering: Whether to use Claude API for filtering
api_key: Anthropic API key for Claude filtering
api_key: Anthropic API key for Claude filtering (ignored when use_vertex)
model: Claude model to use for filtering
custom_filtering_instructions: Optional custom filtering instructions
use_vertex: Route Claude filtering calls through Vertex AI
vertex_region: GCP region for Vertex AI
vertex_project_id: GCP project hosting Vertex AI
"""
self.use_hard_exclusions = use_hard_exclusions
self.use_claude_filtering = use_claude_filtering
self.custom_filtering_instructions = custom_filtering_instructions

# Initialize Claude client if filtering is enabled
self.claude_client = None
if self.use_claude_filtering:
try:
self.claude_client = ClaudeAPIClient(
model=model,
api_key=api_key
api_key=api_key,
use_vertex=use_vertex,
vertex_region=vertex_region,
vertex_project_id=vertex_project_id,
)
# Validate API access
valid, error = self.claude_client.validate_api_access()
Expand Down
28 changes: 21 additions & 7 deletions claudecode/github_action_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,15 @@ def validate_claude_available(self) -> Tuple[bool, str]:
)

if result.returncode == 0:
# Also check if API key is configured
api_key = os.environ.get('ANTHROPIC_API_KEY', '')
if not api_key:
return False, "ANTHROPIC_API_KEY environment variable is not set"
# Also check that auth is configured — either an Anthropic API key
# for direct API access, or Vertex AI credentials.
use_vertex = os.environ.get('CLAUDE_CODE_USE_VERTEX', '').lower() in ('1', 'true')
if use_vertex:
if not os.environ.get('ANTHROPIC_VERTEX_PROJECT_ID'):
return False, "CLAUDE_CODE_USE_VERTEX is set but ANTHROPIC_VERTEX_PROJECT_ID is not"
else:
if not os.environ.get('ANTHROPIC_API_KEY', ''):
return False, "ANTHROPIC_API_KEY environment variable is not set"
return True, ""
else:
error_msg = f"Claude Code returned exit code {result.returncode}"
Expand Down Expand Up @@ -410,14 +415,23 @@ def initialize_findings_filter(custom_filtering_instructions: Optional[str] = No
# Check if we should use Claude API filtering
use_claude_filtering = os.environ.get('ENABLE_CLAUDE_FILTERING', 'false').lower() == 'true'
api_key = os.environ.get('ANTHROPIC_API_KEY')

if use_claude_filtering and api_key:
use_vertex = os.environ.get('CLAUDE_CODE_USE_VERTEX', '').lower() in ('1', 'true')
vertex_project_id = os.environ.get('ANTHROPIC_VERTEX_PROJECT_ID')
vertex_region = os.environ.get('CLOUD_ML_REGION')

# Filtering needs either an API key OR Vertex credentials
has_auth = bool(api_key) or (use_vertex and vertex_project_id)

if use_claude_filtering and has_auth:
# Use full filtering with Claude API
return FindingsFilter(
use_hard_exclusions=True,
use_claude_filtering=True,
api_key=api_key,
custom_filtering_instructions=custom_filtering_instructions
custom_filtering_instructions=custom_filtering_instructions,
use_vertex=use_vertex,
vertex_region=vertex_region,
vertex_project_id=vertex_project_id,
)
else:
# Fallback to filtering with hard rules only
Expand Down
81 changes: 81 additions & 0 deletions claudecode/test_claude_api_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
"""Unit tests for ClaudeAPIClient — covering both direct API and Vertex paths."""

import os
from unittest.mock import patch, MagicMock

import pytest

from claudecode.claude_api_client import ClaudeAPIClient


class TestClaudeAPIClientDirect:
"""Tests for the default api.anthropic.com path."""

@patch('claudecode.claude_api_client.Anthropic')
def test_init_with_api_key(self, mock_anthropic):
client = ClaudeAPIClient(api_key='sk-test-123')
assert client.api_key == 'sk-test-123'
assert client.use_vertex is False
mock_anthropic.assert_called_once_with(api_key='sk-test-123')

@patch('claudecode.claude_api_client.Anthropic')
def test_init_reads_env_api_key(self, mock_anthropic):
with patch.dict(os.environ, {'ANTHROPIC_API_KEY': 'sk-env-456'}, clear=True):
client = ClaudeAPIClient()
assert client.api_key == 'sk-env-456'

def test_init_raises_without_api_key(self):
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match='No Anthropic API key'):
ClaudeAPIClient()


class TestClaudeAPIClientVertex:
"""Tests for the Vertex AI path."""

@patch('claudecode.claude_api_client.AnthropicVertex')
def test_init_with_vertex_args(self, mock_vertex):
client = ClaudeAPIClient(
use_vertex=True,
vertex_region='us-east5',
vertex_project_id='my-project',
)
assert client.use_vertex is True
assert client.api_key is None
mock_vertex.assert_called_once_with(region='us-east5', project_id='my-project')

@patch('claudecode.claude_api_client.AnthropicVertex')
def test_init_reads_vertex_env(self, mock_vertex):
with patch.dict(os.environ, {
'ANTHROPIC_VERTEX_PROJECT_ID': 'env-project',
'CLOUD_ML_REGION': 'global',
}, clear=True):
client = ClaudeAPIClient(use_vertex=True)
assert client.use_vertex is True
mock_vertex.assert_called_once_with(region='global', project_id='env-project')

@patch('claudecode.claude_api_client.AnthropicVertex')
def test_init_defaults_region_to_global(self, mock_vertex):
with patch.dict(os.environ, {}, clear=True):
ClaudeAPIClient(use_vertex=True, vertex_project_id='my-project')
mock_vertex.assert_called_once_with(region='global', project_id='my-project')

def test_init_raises_without_vertex_project(self):
with patch.dict(os.environ, {}, clear=True):
with pytest.raises(ValueError, match='Vertex AI mode requires a project ID'):
ClaudeAPIClient(use_vertex=True)

@patch('claudecode.claude_api_client.AnthropicVertex')
def test_validate_api_access_skipped_in_vertex_mode(self, mock_vertex):
"""Vertex mode should skip the proactive validation call (no test model
guaranteed to be enabled in caller's GCP project)."""
mock_client = MagicMock()
mock_vertex.return_value = mock_client
client = ClaudeAPIClient(use_vertex=True, vertex_project_id='my-project')

ok, err = client.validate_api_access()

assert ok is True
assert err == ''
# No call to messages.create on the underlying client.
mock_client.messages.create.assert_not_called()
Loading