Skip to content
Open
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