Skip to content
Merged
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
74 changes: 74 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,74 @@ The CLI uses intelligent default branch detection with the following priority:

Both `--default-branch` and `--pending-head` parameters are automatically synchronized to ensure consistent behavior.

## GitLab Token Configuration

The CLI supports GitLab integration with automatic authentication pattern detection for different token types.

### Supported Token Types

GitLab API supports two authentication methods, and the CLI automatically detects which one to use:

1. **Bearer Token Authentication** (`Authorization: Bearer <token>`)
- GitLab CI Job Tokens (`$CI_JOB_TOKEN`)
- Personal Access Tokens with `glpat-` prefix
- OAuth 2.0 tokens (long alphanumeric tokens)

2. **Private Token Authentication** (`PRIVATE-TOKEN: <token>`)
- Legacy personal access tokens
- Custom tokens that don't match Bearer patterns

### Token Detection Logic

The CLI automatically determines the authentication method using this logic:

```
if token == $CI_JOB_TOKEN:
use Bearer authentication
elif token starts with "glpat-":
use Bearer authentication
elif token is long (>40 chars) and alphanumeric:
use Bearer authentication
else:
use PRIVATE-TOKEN authentication
```

### Automatic Fallback

If the initial authentication method fails with a 401 error, the CLI automatically retries with the alternative method:

- **Bearer → PRIVATE-TOKEN**: If Bearer authentication fails, retry with PRIVATE-TOKEN
- **PRIVATE-TOKEN → Bearer**: If PRIVATE-TOKEN fails, retry with Bearer authentication

This ensures maximum compatibility across different GitLab configurations and token types.

### Environment Variables

| Variable | Description | Example |
|:---------|:------------|:--------|
| `GITLAB_TOKEN` | GitLab API token (required for GitLab integration) | `glpat-xxxxxxxxxxxxxxxxxxxx` |
| `CI_JOB_TOKEN` | GitLab CI job token (automatically used in GitLab CI) | Automatically provided by GitLab CI |

### Usage Examples

**GitLab CI with job token (recommended):**
```yaml
variables:
GITLAB_TOKEN: $CI_JOB_TOKEN
```

**GitLab CI with personal access token:**
```yaml
variables:
GITLAB_TOKEN: $GITLAB_PERSONAL_ACCESS_TOKEN # Set in GitLab project/group variables
```

**Local development:**
```bash
export GITLAB_TOKEN="glpat-your-personal-access-token"
socketcli --integration gitlab --repo owner/repo --pr-number 123
```

### Scan Behavior

The CLI determines scanning behavior intelligently:
Expand Down Expand Up @@ -340,4 +408,10 @@ Implementation targets:

### Environment Variables

#### Core Configuration
- `SOCKET_SECURITY_API_KEY`: Socket Security API token (alternative to --api-token parameter)
- `SOCKET_SDK_PATH`: Path to local socket-sdk-python repository (default: ../socket-sdk-python)

#### GitLab Integration
- `GITLAB_TOKEN`: GitLab API token for GitLab integration (supports both Bearer and PRIVATE-TOKEN authentication)
- `CI_JOB_TOKEN`: GitLab CI job token (automatically provided in GitLab CI environments)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"

[project]
name = "socketsecurity"
version = "2.2.0"
version = "2.2.2"
requires-python = ">= 3.10"
license = {"file" = "LICENSE"}
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
__author__ = 'socket.dev'
__version__ = '2.2.0'
__version__ = '2.2.2'
47 changes: 45 additions & 2 deletions socketsecurity/core/scm/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,51 @@ def get_headers(self) -> Dict:

class GitlabClient(ScmClient):
def get_headers(self) -> Dict:
return {
'Authorization': f"Bearer {self.token}",
"""
Determine the appropriate authentication headers for GitLab API.
Uses the same logic as GitlabConfig._get_auth_headers()
"""
return self._get_gitlab_auth_headers(self.token)

@staticmethod
def _get_gitlab_auth_headers(token: str) -> dict:
"""
Determine the appropriate authentication headers for GitLab API.

GitLab supports two authentication patterns:
1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope)
2. Private token (personal access tokens)
"""
import os

base_headers = {
'User-Agent': 'SocketPythonScript/0.0.1',
"accept": "application/json"
}

# Check if this is a GitLab CI job token
if token == os.getenv('CI_JOB_TOKEN'):
return {
**base_headers,
'Authorization': f"Bearer {token}"
}

# Check for personal access token pattern
if token.startswith('glpat-'):
return {
**base_headers,
'Authorization': f"Bearer {token}"
}

# Check for OAuth token pattern (typically longer and alphanumeric)
if len(token) > 40 and token.isalnum():
return {
**base_headers,
'Authorization': f"Bearer {token}"
}

# Default to PRIVATE-TOKEN for other token types
return {
**base_headers,
'PRIVATE-TOKEN': f"{token}"
}
120 changes: 112 additions & 8 deletions socketsecurity/core/scm/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ def from_env(cls) -> 'GitlabConfig':
mr_source_branch = os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_NAME')
default_branch = os.getenv('CI_DEFAULT_BRANCH', '')

# Determine which authentication pattern to use
headers = cls._get_auth_headers(token)

return cls(
commit_sha=os.getenv('CI_COMMIT_SHA', ''),
api_url=os.getenv('CI_API_V4_URL', ''),
Expand All @@ -57,18 +60,119 @@ def from_env(cls) -> 'GitlabConfig':
token=token,
repository=project_name,
is_default_branch=(mr_source_branch == default_branch if mr_source_branch else False),
headers={
'Authorization': f"Bearer {token}",
'User-Agent': 'SocketPythonScript/0.0.1',
"accept": "application/json"
}
headers=headers
)

@staticmethod
def _get_auth_headers(token: str) -> dict:
"""
Determine the appropriate authentication headers for GitLab API.

GitLab supports two authentication patterns:
1. Bearer token (OAuth 2.0 tokens, personal access tokens with api scope)
2. Private token (personal access tokens)

Logic for token type determination:
- CI_JOB_TOKEN: Always use Bearer (GitLab CI job token)
- Tokens starting with 'glpat-': Personal access tokens, try Bearer first
- OAuth tokens: Use Bearer
- Other tokens: Use PRIVATE-TOKEN as fallback
"""
base_headers = {
'User-Agent': 'SocketPythonScript/0.0.1',
"accept": "application/json"
}

# Check if this is a GitLab CI job token
if token == os.getenv('CI_JOB_TOKEN'):
log.debug("Using Bearer authentication for GitLab CI job token")
return {
**base_headers,
'Authorization': f"Bearer {token}"
}

# Check for personal access token pattern
if token.startswith('glpat-'):
log.debug("Using Bearer authentication for GitLab personal access token")
return {
**base_headers,
'Authorization': f"Bearer {token}"
}

# Check for OAuth token pattern (typically longer and alphanumeric)
if len(token) > 40 and token.isalnum():
log.debug("Using Bearer authentication for potential OAuth token")
return {
**base_headers,
'Authorization': f"Bearer {token}"
}

# Default to PRIVATE-TOKEN for other token types
log.debug("Using PRIVATE-TOKEN authentication for GitLab token")
return {
**base_headers,
'PRIVATE-TOKEN': f"{token}"
}

class Gitlab:
def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None):
self.config = config or GitlabConfig.from_env()
self.client = client

def _request_with_fallback(self, **kwargs):
"""
Make a request with automatic fallback between Bearer and PRIVATE-TOKEN authentication.
This provides robustness when the initial token type detection is incorrect.
"""
try:
# Try the initial request with the configured headers
return self.client.request(**kwargs)
except Exception as e:
# Check if this is an authentication error (401)
if hasattr(e, 'response') and e.response and e.response.status_code == 401:
log.debug(f"Authentication failed with initial headers, trying fallback method")

# Determine the fallback headers
original_headers = kwargs.get('headers', self.config.headers)
fallback_headers = self._get_fallback_headers(original_headers)

if fallback_headers and fallback_headers != original_headers:
log.debug("Retrying request with fallback authentication method")
kwargs['headers'] = fallback_headers
return self.client.request(**kwargs)

# Re-raise the original exception if it's not an auth error or fallback failed
raise

def _get_fallback_headers(self, original_headers: dict) -> dict:
"""
Generate fallback authentication headers.
If using Bearer, fallback to PRIVATE-TOKEN and vice versa.
"""
base_headers = {
'User-Agent': 'SocketPythonScript/0.0.1',
"accept": "application/json"
}

# If currently using Bearer, try PRIVATE-TOKEN
if 'Authorization' in original_headers and 'Bearer' in original_headers['Authorization']:
log.debug("Falling back from Bearer to PRIVATE-TOKEN authentication")
return {
**base_headers,
'PRIVATE-TOKEN': f"{self.config.token}"
}

# If currently using PRIVATE-TOKEN, try Bearer
elif 'PRIVATE-TOKEN' in original_headers:
log.debug("Falling back from PRIVATE-TOKEN to Bearer authentication")
return {
**base_headers,
'Authorization': f"Bearer {self.config.token}"
}

# No fallback available
return None

def check_event_type(self) -> str:
pipeline_source = self.config.pipeline_source.lower()
if pipeline_source in ["web", 'merge_request_event', "push", "api"]:
Expand All @@ -84,7 +188,7 @@ def check_event_type(self) -> str:
def post_comment(self, body: str) -> None:
path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes"
payload = {"body": body}
self.client.request(
self._request_with_fallback(
path=path,
payload=payload,
method="POST",
Expand All @@ -95,7 +199,7 @@ def post_comment(self, body: str) -> None:
def update_comment(self, body: str, comment_id: str) -> None:
path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}"
payload = {"body": body}
self.client.request(
self._request_with_fallback(
path=path,
payload=payload,
method="PUT",
Expand All @@ -106,7 +210,7 @@ def update_comment(self, body: str, comment_id: str) -> None:
def get_comments_for_pr(self) -> dict:
log.debug(f"Getting Gitlab comments for Repo {self.config.repository} for PR {self.config.mr_iid}")
path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes"
response = self.client.request(
response = self._request_with_fallback(
path=path,
headers=self.config.headers,
base_url=self.config.api_url
Expand Down
Loading
Loading