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
1 change: 1 addition & 0 deletions .github/workflows/sast.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ jobs:
comment-pr: true
upload-results: true
exclude-directories: "tests/vulnerable"
claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
run-every-commit: true
33 changes: 31 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
- uses: anthropics/claude-code-security-review@main
with:
comment-pr: true
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
```

## Configuration Options
Expand All @@ -46,7 +46,8 @@ jobs:

| Input | Description | Default | Required |
|-------|-------------|---------|----------|
| `claude-api-key` | Anthropic Claude API key for security analysis. <br>*Note*: This API key needs to be enabled for both the Claude API and Claude Code usage. | None | Yes |
| `claude-api-key` | Anthropic Claude API key for security analysis. <br>*Note*: This API key needs to be enabled for both the Claude API and Claude Code usage. | None | No* |
| `claude-code-oauth-token` | Claude Code OAuth token for authentication (alternative to API key). Recommended for Claude subscription users. | None | No* |
| `comment-pr` | Whether to comment on PRs with findings | `true` | No |
| `upload-results` | Whether to upload results as artifacts | `true` | No |
| `exclude-directories` | Comma-separated list of directories to exclude from scanning | None | No |
Expand All @@ -56,13 +57,41 @@ jobs:
| `false-positive-filtering-instructions` | Path to custom false positive filtering instructions text file | None | No |
| `custom-security-scan-instructions` | Path to custom security scan instructions text file to append to audit prompt | None | No |

*Either `claude-api-key` or `claude-code-oauth-token` is required for authentication.

### Action Outputs

| Output | Description |
|--------|-------------|
| `findings-count` | Total number of security findings |
| `results-file` | Path to the results JSON file |

## Authentication

Choose one of the following authentication methods:

**Option 1: OAuth Token (Recommended)**
```yaml
- uses: anthropics/claude-code-security-review@main
with:
claude-code-oauth-token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
```

To get an OAuth token, Pro and Max users can run:
```bash
claude setup-token
```
Then add the generated token to your repository secrets as `CLAUDE_CODE_OAUTH_TOKEN`.

**Option 2: API Key**
```yaml
- uses: anthropics/claude-code-security-review@main
with:
claude-api-key: ${{ secrets.CLAUDE_API_KEY }}
```

OAuth tokens are recommended for users with Claude subscriptions as they avoid API usage charges.

## How It Works

### Architecture
Expand Down
28 changes: 22 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,12 @@ inputs:

claude-api-key:
description: 'Anthropic Claude API key for security analysis'
required: true
required: false
default: ''

claude-code-oauth-token:
description: 'Claude Code OAuth token for authentication (alternative to API key)'
required: false
default: ''

claude-model:
Expand Down Expand Up @@ -182,6 +187,7 @@ runs:
GITHUB_REPOSITORY: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
ANTHROPIC_API_KEY: ${{ inputs.claude-api-key }}
CLAUDE_CODE_OAUTH_TOKEN: ${{ inputs.claude-code-oauth-token }}
ENABLE_CLAUDE_FILTERING: 'true'
EXCLUDE_DIRECTORIES: ${{ inputs.exclude-directories }}
FALSE_POSITIVE_FILTERING_INSTRUCTIONS: ${{ inputs.false-positive-filtering-instructions }}
Expand All @@ -203,13 +209,17 @@ 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:"
# Validate authentication is provided (either API key or OAuth token)
if [ -z "$ANTHROPIC_API_KEY" ] && [ -z "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
echo "::error::No authentication provided. Please provide either claude-api-key or claude-code-oauth-token input to the action."
echo "Example usage with API key:"
echo " - uses: anthropics/claude-code-security-reviewer@main"
echo " with:"
echo " claude-api-key: \$\{{ secrets.ANTHROPIC_API_KEY }}"
echo "Example usage with OAuth token:"
echo " - uses: anthropics/claude-code-security-reviewer@main"
echo " with:"
echo " claude-code-oauth-token: \$\{{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}"
exit 1
fi

Expand All @@ -225,7 +235,13 @@ runs:
echo "Current directory: $(pwd)"
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)"
if [ -n "$CLAUDE_CODE_OAUTH_TOKEN" ]; then
echo "Authentication method: OAuth token"
elif [ -n "$ANTHROPIC_API_KEY" ]; then
echo "Authentication method: API key"
else
echo "Authentication method: None (this will cause an error)"
fi
echo "GITHUB_REPOSITORY: $GITHUB_REPOSITORY"
echo "PR_NUMBER: $PR_NUMBER"
echo "Python path: $PYTHONPATH"
Expand Down
37 changes: 24 additions & 13 deletions claudecode/claude_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,34 +21,42 @@
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,
oauth_token: Optional[str] = None,
timeout_seconds: Optional[int] = None,
max_retries: Optional[int] = 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)
oauth_token: Claude Code OAuth token (if None, reads from CLAUDE_CODE_OAUTH_TOKEN env var)
timeout_seconds: Request timeout in seconds
max_retries: Maximum retry attempts for API calls
"""
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

# Get authentication from environment or parameters
# Priority: OAuth token first, then API key
self.oauth_token = oauth_token or os.environ.get("CLAUDE_CODE_OAUTH_TOKEN")
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY")
if not self.api_key:

# Initialize Anthropic client with appropriate authentication
if self.oauth_token:
self.client = Anthropic(auth_token=self.oauth_token)
logger.info("Claude API client initialized with OAuth token")
elif self.api_key:
self.client = Anthropic(api_key=self.api_key)
logger.info("Claude API client initialized with API key")
else:
raise ValueError(
"No Anthropic API key found. Please set ANTHROPIC_API_KEY environment variable "
"or provide api_key parameter."
"No authentication found. Please set either CLAUDE_CODE_OAUTH_TOKEN or ANTHROPIC_API_KEY environment variable "
"or provide oauth_token or api_key parameter."
)

# Initialize Anthropic client
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.
Expand Down Expand Up @@ -356,20 +364,23 @@ 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,
oauth_token: Optional[str] = None,
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS) -> ClaudeAPIClient:
"""Convenience function to get Claude API client.

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

Returns:
Initialized ClaudeAPIClient instance
"""
return ClaudeAPIClient(
model=model,
api_key=api_key,
oauth_token=oauth_token,
timeout_seconds=timeout_seconds
)

Expand Down
2 changes: 1 addition & 1 deletion claudecode/evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ The evaluation tool allows you to run the Claude Code Security Reviewer on any G
- Git 2.20+ (for worktree support)
- GitHub CLI (`gh`) for API access
- Environment variables:
- `ANTHROPIC_API_KEY`: Required for Claude API access
- `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY`: Required for authentication
- `GITHUB_TOKEN`: Recommended for GitHub API rate limits

## Usage
Expand Down
11 changes: 7 additions & 4 deletions claudecode/findings_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -157,32 +157,35 @@ 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,
oauth_token: Optional[str] = None,
model: str = DEFAULT_CLAUDE_MODEL,
custom_filtering_instructions: 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
oauth_token: Claude Code OAuth token for Claude filtering
model: Claude model to use for filtering
custom_filtering_instructions: Optional custom filtering instructions
"""
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,
oauth_token=oauth_token
)
# Validate API access
valid, error = self.claude_client.validate_api_access()
Expand Down
19 changes: 11 additions & 8 deletions claudecode/github_action_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,10 +322,11 @@ def validate_claude_available(self) -> Tuple[bool, str]:
)

if result.returncode == 0:
# Also check if API key is configured
# Check if authentication is configured (OAuth token or API key)
oauth_token = os.environ.get('CLAUDE_CODE_OAUTH_TOKEN', '')
api_key = os.environ.get('ANTHROPIC_API_KEY', '')
if not api_key:
return False, "ANTHROPIC_API_KEY environment variable is not set"
if not oauth_token and not api_key:
return False, "Neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY environment variable is set"
return True, ""
else:
error_msg = f"Claude Code returned exit code {result.returncode}"
Expand Down Expand Up @@ -395,27 +396,29 @@ def initialize_clients() -> Tuple[GitHubActionClient, SimpleClaudeRunner]:

def initialize_findings_filter(custom_filtering_instructions: Optional[str] = None) -> FindingsFilter:
"""Initialize findings filter based on environment configuration.

Args:
custom_filtering_instructions: Optional custom filtering instructions

Returns:
FindingsFilter instance

Raises:
ConfigurationError: If filter initialization fails
"""
try:
# Check if we should use Claude API filtering
use_claude_filtering = os.environ.get('ENABLE_CLAUDE_FILTERING', 'false').lower() == 'true'
oauth_token = os.environ.get('CLAUDE_CODE_OAUTH_TOKEN')
api_key = os.environ.get('ANTHROPIC_API_KEY')
if use_claude_filtering and api_key:

if use_claude_filtering and (oauth_token or api_key):
# Use full filtering with Claude API
return FindingsFilter(
use_hard_exclusions=True,
use_claude_filtering=True,
api_key=api_key,
oauth_token=oauth_token,
custom_filtering_instructions=custom_filtering_instructions
)
else:
Expand Down
50 changes: 50 additions & 0 deletions claudecode/test_claude_api_client_oauth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
"""
Unit tests for ClaudeAPIClient OAuth token support.
"""

import os
import pytest
from unittest.mock import Mock, patch

from claudecode.claude_api_client import ClaudeAPIClient


class TestClaudeAPIClientOAuth:
"""Test ClaudeAPIClient OAuth token functionality."""

@patch('claudecode.claude_api_client.Anthropic')
def test_oauth_token_priority_over_api_key(self, mock_anthropic):
"""Test that OAuth token takes priority over API key when both provided."""
mock_anthropic.return_value = Mock()

client = ClaudeAPIClient(
oauth_token='test-oauth-token',
api_key='test-api-key'
)

mock_anthropic.assert_called_once_with(auth_token='test-oauth-token')

@patch('claudecode.claude_api_client.Anthropic')
def test_oauth_env_priority_over_api_key_env(self, mock_anthropic):
"""Test that OAuth token env var takes priority over API key env var."""
mock_anthropic.return_value = Mock()

with patch.dict(os.environ, {
'CLAUDE_CODE_OAUTH_TOKEN': 'env-oauth-token',
'ANTHROPIC_API_KEY': 'env-api-key'
}):
client = ClaudeAPIClient()

mock_anthropic.assert_called_once_with(auth_token='env-oauth-token')

@patch('claudecode.claude_api_client.Anthropic')
def test_backward_compatibility_api_key_only(self, mock_anthropic):
"""Test that existing API key functionality still works unchanged."""
mock_anthropic.return_value = Mock()

client = ClaudeAPIClient(api_key='existing-api-key')

mock_anthropic.assert_called_once_with(api_key='existing-api-key')
assert client.api_key == 'existing-api-key'
assert client.oauth_token is None
15 changes: 8 additions & 7 deletions claudecode/test_claude_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,24 +47,25 @@ def test_validate_claude_available_success(self, mock_run):
)

@patch('subprocess.run')
def test_validate_claude_available_no_api_key(self, mock_run):
"""Test Claude validation without API key."""
def test_validate_claude_available_no_authentication(self, mock_run):
"""Test Claude validation without any authentication."""
mock_run.return_value = Mock(
returncode=0,
stdout='claude version 1.0.0',
stderr=''
)
# Remove API key

# Remove both authentication methods
env = os.environ.copy()
env.pop('ANTHROPIC_API_KEY', None)

env.pop('CLAUDE_CODE_OAUTH_TOKEN', None)

with patch.dict(os.environ, env, clear=True):
runner = SimpleClaudeRunner()
success, error = runner.validate_claude_available()

assert success is False
assert 'ANTHROPIC_API_KEY environment variable is not set' in error
assert 'Neither CLAUDE_CODE_OAUTH_TOKEN nor ANTHROPIC_API_KEY environment variable is set' in error

@patch('subprocess.run')
def test_validate_claude_available_not_installed(self, mock_run):
Expand Down
Loading