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
54 changes: 54 additions & 0 deletions instructions/gitlab-commit-status/uat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# UAT: GitLab Commit Status Integration

## Feature
`--enable-commit-status` posts a commit status (`success`/`failed`) to GitLab after scan completes. Repo admins can then require `socket-security` as a status check on protected branches.

## Prerequisites
- GitLab project with CI/CD configured
- `GITLAB_TOKEN` with `api` scope (or `CI_JOB_TOKEN` with sufficient permissions)
- Merge request pipeline (so `CI_MERGE_REQUEST_PROJECT_ID` is set)

## Test Cases

### 1. Pass scenario (no blocking alerts)
1. Create MR with no dependency changes (or only safe ones)
2. Run: `socketcli --scm gitlab --enable-commit-status`
3. **Expected**: Commit status `socket-security` = `success`, description = "No blocking issues"
4. Verify in GitLab: **Repository > Commits > (sha) > Pipelines** or **MR > Pipeline > External** tab

### 2. Fail scenario (blocking alerts)
1. Create MR adding a package with known blocking alerts
2. Run: `socketcli --scm gitlab --enable-commit-status`
3. **Expected**: Commit status = `failed`, description = "N blocking alert(s) found"

### 3. Flag omitted (default off)
1. Run: `socketcli --scm gitlab` (no `--enable-commit-status`)
2. **Expected**: No commit status posted

### 4. Non-MR pipeline (push event without MR)
1. Trigger pipeline on a push (no MR context)
2. Run: `socketcli --scm gitlab --enable-commit-status`
3. **Expected**: Commit status skipped (no `mr_project_id`), no error

### 5. API failure is non-fatal
1. Use an invalid/revoked `GITLAB_TOKEN`
2. Run: `socketcli --scm gitlab --enable-commit-status`
3. **Expected**: Error logged ("Failed to set commit status: ..."), scan still completes with correct exit code

### 6. Non-GitLab SCM
1. Run: `socketcli --scm github --enable-commit-status`
2. **Expected**: Flag is accepted but commit status is not posted (GitHub not yet supported)

## Blocking Merges on Failure

### Option A: Pipelines must succeed (all GitLab tiers)
Since `socketcli` exits with code 1 when blocking alerts are found, the pipeline fails automatically.
1. Go to **Settings > General > Merge requests**
2. Under **Merge checks**, enable **"Pipelines must succeed"**
3. Save — GitLab will now prevent merging when the pipeline fails

### Option B: External status checks (GitLab Ultimate only)
Use the `socket-security` commit status as a required external check.
1. Go to **Settings > General > Merge requests > Status checks**
2. Add an external status check with name `socket-security`
3. MRs will require Socket's `success` status to merge
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.72"
version = "2.2.73"
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,3 +1,3 @@
__author__ = 'socket.dev'
__version__ = '2.2.72'
__version__ = '2.2.73'
USER_AGENT = f'SocketPythonCLI/{__version__}'
14 changes: 14 additions & 0 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class CliConfig:
only_facts_file: bool = False
reach_use_only_pregenerated_sboms: bool = False
max_purl_batch_size: int = 5000
enable_commit_status: bool = False

@classmethod
def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
Expand Down Expand Up @@ -164,6 +165,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
'only_facts_file': args.only_facts_file,
'reach_use_only_pregenerated_sboms': args.reach_use_only_pregenerated_sboms,
'max_purl_batch_size': args.max_purl_batch_size,
'enable_commit_status': args.enable_commit_status,
'version': __version__
}
try:
Expand Down Expand Up @@ -512,6 +514,18 @@ def create_argument_parser() -> argparse.ArgumentParser:
action="store_true",
help=argparse.SUPPRESS
)
output_group.add_argument(
"--enable-commit-status",
dest="enable_commit_status",
action="store_true",
help="Report scan result as a commit status on GitLab (requires GitLab SCM)"
)
output_group.add_argument(
"--enable_commit_status",
dest="enable_commit_status",
action="store_true",
help=argparse.SUPPRESS
)

# Plugin Configuration
plugin_group = parser.add_argument_group('Plugin Configuration')
Expand Down
68 changes: 67 additions & 1 deletion socketsecurity/core/scm/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,15 @@ def from_env(cls) -> 'GitlabConfig':
# Determine which authentication pattern to use
headers = cls._get_auth_headers(token)

# Prefer source branch SHA (real commit) over CI_COMMIT_SHA which
# may be a synthetic merge-result commit in merged-results pipelines.
commit_sha = (
os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA') or
os.getenv('CI_COMMIT_SHA', '')
)

return cls(
commit_sha=os.getenv('CI_COMMIT_SHA', ''),
commit_sha=commit_sha,
api_url=os.getenv('CI_API_V4_URL', ''),
project_dir=os.getenv('CI_PROJECT_DIR', ''),
mr_source_branch=mr_source_branch,
Expand Down Expand Up @@ -260,6 +267,65 @@ def add_socket_comments(
log.debug("No Previous version of Security Issue comment, posting")
self.post_comment(security_comment)

def enable_merge_pipeline_check(self) -> None:
"""Enable 'only_allow_merge_if_pipeline_succeeds' on the MR target project."""
if not self.config.mr_project_id:
return
url = f"{self.config.api_url}/projects/{self.config.mr_project_id}"
try:
resp = requests.put(
url,
json={"only_allow_merge_if_pipeline_succeeds": True},
headers=self.config.headers,
)
if resp.status_code == 401:
fallback = self._get_fallback_headers(self.config.headers)
if fallback:
resp = requests.put(
url,
json={"only_allow_merge_if_pipeline_succeeds": True},
headers=fallback,
)
if resp.status_code >= 400:
log.error(f"GitLab enable merge check API {resp.status_code}: {resp.text}")
else:
log.info("Enabled 'pipelines must succeed' merge check on project")
except Exception as e:
log.error(f"Failed to enable merge pipeline check: {e}")

def set_commit_status(self, state: str, description: str, target_url: str = '') -> None:
"""Post a commit status to GitLab. state should be 'success' or 'failed'.

Uses requests.post with json= directly because CliClient.request sends
data= (form-encoded) which GitLab's commit status endpoint rejects.
"""
if not self.config.mr_project_id:
log.debug("No mr_project_id, skipping commit status")
return
url = f"{self.config.api_url}/projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}"
payload = {
"state": state,
"context": "socket-security-commit-status",
"description": description,
}
if self.config.mr_source_branch:
payload["ref"] = self.config.mr_source_branch
if target_url:
payload["target_url"] = target_url
try:
log.debug(f"Posting commit status to {url}")
resp = requests.post(url, json=payload, headers=self.config.headers)
if resp.status_code == 401:
fallback = self._get_fallback_headers(self.config.headers)
if fallback:
resp = requests.post(url, json=payload, headers=fallback)
if resp.status_code >= 400:
log.error(f"GitLab commit status API {resp.status_code}: {resp.text}")
resp.raise_for_status()
log.info(f"Commit status set to '{state}' on {self.config.commit_sha[:8]}")
except Exception as e:
log.error(f"Failed to set commit status: {e}")

def remove_comment_alerts(self, comments: dict):
security_alert = comments.get("security")
if security_alert is not None:
Expand Down
15 changes: 15 additions & 0 deletions socketsecurity/socketcli.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,21 @@ def main_code():
log.debug("Temporarily enabling disable_blocking due to no supported manifest files")
config.disable_blocking = True

# Post commit status to GitLab if enabled
if config.enable_commit_status and scm is not None:
from socketsecurity.core.scm.gitlab import Gitlab
if isinstance(scm, Gitlab) and scm.config.mr_project_id:
scm.enable_merge_pipeline_check()
passed = output_handler.report_pass(diff)
state = "success" if passed else "failed"
blocking_count = sum(1 for a in diff.new_alerts if a.error)
if passed:
description = "No blocking issues"
else:
description = f"{blocking_count} blocking alert(s) found"
target_url = diff.report_url or diff.diff_url or ""
scm.set_commit_status(state, description, target_url)

sys.exit(output_handler.return_exit_code(diff))


Expand Down
184 changes: 184 additions & 0 deletions tests/unit/test_gitlab_commit_status.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
"""Tests for GitLab commit status integration"""
import os
import pytest
from unittest.mock import patch, MagicMock, call

from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig


def _make_gitlab_config(**overrides):
defaults = dict(
commit_sha="abc123def456",
api_url="https://gitlab.example.com/api/v4",
project_dir="/builds/test",
mr_source_branch="feature",
mr_iid="42",
mr_project_id="99",
commit_message="test commit",
default_branch="main",
project_name="test-project",
pipeline_source="merge_request_event",
commit_author="dev@example.com",
token="glpat-test",
repository="test-project",
is_default_branch=False,
headers={"Authorization": "Bearer glpat-test", "accept": "application/json"},
)
defaults.update(overrides)
return GitlabConfig(**defaults)


class TestSetCommitStatus:
"""Test Gitlab.set_commit_status()"""

@patch("socketsecurity.core.scm.gitlab.requests.post")
def test_calls_correct_url_and_json_payload(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

gl.set_commit_status("success", "No blocking issues", "https://app.socket.dev/report/123")

mock_post.assert_called_once_with(
"https://gitlab.example.com/api/v4/projects/99/statuses/abc123def456",
json={
"state": "success",
"context": "socket-security-commit-status",
"description": "No blocking issues",
"ref": "feature",
"target_url": "https://app.socket.dev/report/123",
},
headers=config.headers,
)

@patch("socketsecurity.core.scm.gitlab.requests.post")
def test_failed_state_payload(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

gl.set_commit_status("failed", "3 blocking alert(s) found")

payload = mock_post.call_args.kwargs["json"]
assert payload["state"] == "failed"
assert payload["description"] == "3 blocking alert(s) found"
assert "target_url" not in payload

@patch("socketsecurity.core.scm.gitlab.requests.post")
def test_skipped_when_no_mr_project_id(self, mock_post):
config = _make_gitlab_config(mr_project_id=None)
gl = Gitlab(client=MagicMock(), config=config)

gl.set_commit_status("success", "No blocking issues")

mock_post.assert_not_called()

@patch("socketsecurity.core.scm.gitlab.requests.post")
def test_graceful_error_handling(self, mock_post):
mock_post.side_effect = Exception("connection error")
config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

# Should not raise
gl.set_commit_status("success", "No blocking issues")

@patch("socketsecurity.core.scm.gitlab.requests.post")
def test_no_target_url_omitted_from_payload(self, mock_post):
mock_post.return_value = MagicMock(status_code=200)
config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

gl.set_commit_status("success", "No blocking issues", target_url="")

payload = mock_post.call_args.kwargs["json"]
assert "target_url" not in payload

@patch("socketsecurity.core.scm.gitlab.requests.post")
def test_auth_fallback_on_401(self, mock_post):
resp_401 = MagicMock(status_code=401)
resp_401.raise_for_status.side_effect = Exception("401")
resp_200 = MagicMock(status_code=200)
mock_post.side_effect = [resp_401, resp_200]

config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

gl.set_commit_status("success", "No blocking issues")

assert mock_post.call_count == 2
# Second call should use fallback headers (PRIVATE-TOKEN)
fallback_headers = mock_post.call_args_list[1].kwargs["headers"]
assert "PRIVATE-TOKEN" in fallback_headers


class TestEnableMergePipelineCheck:
"""Test Gitlab.enable_merge_pipeline_check()"""

@patch("socketsecurity.core.scm.gitlab.requests.put")
def test_calls_correct_url_and_payload(self, mock_put):
mock_put.return_value = MagicMock(status_code=200)
config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

gl.enable_merge_pipeline_check()

mock_put.assert_called_once_with(
"https://gitlab.example.com/api/v4/projects/99",
json={"only_allow_merge_if_pipeline_succeeds": True},
headers=config.headers,
)

@patch("socketsecurity.core.scm.gitlab.requests.put")
def test_skipped_when_no_mr_project_id(self, mock_put):
config = _make_gitlab_config(mr_project_id=None)
gl = Gitlab(client=MagicMock(), config=config)

gl.enable_merge_pipeline_check()

mock_put.assert_not_called()

@patch("socketsecurity.core.scm.gitlab.requests.put")
def test_auth_fallback_on_401(self, mock_put):
resp_401 = MagicMock(status_code=401)
resp_200 = MagicMock(status_code=200)
mock_put.side_effect = [resp_401, resp_200]

config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

gl.enable_merge_pipeline_check()

assert mock_put.call_count == 2
fallback_headers = mock_put.call_args_list[1].kwargs["headers"]
assert "PRIVATE-TOKEN" in fallback_headers

@patch("socketsecurity.core.scm.gitlab.requests.put")
def test_graceful_error_handling(self, mock_put):
mock_put.side_effect = Exception("connection error")
config = _make_gitlab_config()
gl = Gitlab(client=MagicMock(), config=config)

# Should not raise
gl.enable_merge_pipeline_check()


class TestEnableCommitStatusCliArg:
"""Test --enable-commit-status CLI argument parsing"""

def test_default_is_false(self):
from socketsecurity.config import create_argument_parser
parser = create_argument_parser()
args = parser.parse_args([])
assert args.enable_commit_status is False

def test_flag_sets_true(self):
from socketsecurity.config import create_argument_parser
parser = create_argument_parser()
args = parser.parse_args(["--enable-commit-status"])
assert args.enable_commit_status is True

def test_underscore_alias(self):
from socketsecurity.config import create_argument_parser
parser = create_argument_parser()
args = parser.parse_args(["--enable_commit_status"])
assert args.enable_commit_status is True
Loading
Loading