From 6093eb5b37f20132a743785f7ab2b5a55f1af831 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 14:37:11 -0800 Subject: [PATCH 1/9] added ability to prevent merges based on failed check run --- socketsecurity/config.py | 14 +++ socketsecurity/core/scm/gitlab.py | 25 +++++ socketsecurity/socketcli.py | 14 +++ tests/unit/test_gitlab_commit_status.py | 118 ++++++++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 tests/unit/test_gitlab_commit_status.py diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 5105281..7a94aca 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -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': @@ -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: @@ -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') diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 70abf50..f0a49c6 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -260,6 +260,31 @@ def add_socket_comments( log.debug("No Previous version of Security Issue comment, posting") self.post_comment(security_comment) + 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'.""" + if not self.config.mr_project_id: + log.debug("No mr_project_id, skipping commit status") + return + path = f"projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}" + payload = { + "state": state, + "name": "socket-security", + "description": description, + } + if target_url: + payload["target_url"] = target_url + try: + self._request_with_fallback( + path=path, + payload=payload, + method="POST", + headers=self.config.headers, + base_url=self.config.api_url + ) + 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: diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 194da44..7c26954 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -641,6 +641,20 @@ 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: + 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)) diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py new file mode 100644 index 0000000..4742a5a --- /dev/null +++ b/tests/unit/test_gitlab_commit_status.py @@ -0,0 +1,118 @@ +"""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()""" + + def test_calls_correct_api_path(self): + config = _make_gitlab_config() + client = MagicMock() + gl = Gitlab(client=client, config=config) + gl._request_with_fallback = MagicMock() + + gl.set_commit_status("success", "No blocking issues", "https://app.socket.dev/report/123") + + gl._request_with_fallback.assert_called_once_with( + path="projects/99/statuses/abc123def456", + payload={ + "state": "success", + "name": "socket-security", + "description": "No blocking issues", + "target_url": "https://app.socket.dev/report/123", + }, + method="POST", + headers=config.headers, + base_url=config.api_url, + ) + + def test_failed_state_payload(self): + config = _make_gitlab_config() + client = MagicMock() + gl = Gitlab(client=client, config=config) + gl._request_with_fallback = MagicMock() + + gl.set_commit_status("failed", "3 blocking alert(s) found") + + args = gl._request_with_fallback.call_args + assert args.kwargs["payload"]["state"] == "failed" + assert args.kwargs["payload"]["description"] == "3 blocking alert(s) found" + assert "target_url" not in args.kwargs["payload"] + + def test_skipped_when_no_mr_project_id(self): + config = _make_gitlab_config(mr_project_id=None) + client = MagicMock() + gl = Gitlab(client=client, config=config) + gl._request_with_fallback = MagicMock() + + gl.set_commit_status("success", "No blocking issues") + + gl._request_with_fallback.assert_not_called() + + def test_graceful_error_handling(self): + config = _make_gitlab_config() + client = MagicMock() + gl = Gitlab(client=client, config=config) + gl._request_with_fallback = MagicMock(side_effect=Exception("API error")) + + # Should not raise + gl.set_commit_status("success", "No blocking issues") + + def test_no_target_url_omitted_from_payload(self): + config = _make_gitlab_config() + client = MagicMock() + gl = Gitlab(client=client, config=config) + gl._request_with_fallback = MagicMock() + + gl.set_commit_status("success", "No blocking issues", target_url="") + + payload = gl._request_with_fallback.call_args.kwargs["payload"] + assert "target_url" not in payload + + +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 From af2f0993fa4f4a0abb5a28f235971313c5f706d6 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 15:11:22 -0800 Subject: [PATCH 2/9] updated json format for gitlab api call --- instructions/gitlab-commit-status/uat.md | 46 ++++++++++++++ socketsecurity/core/scm/gitlab.py | 21 ++++--- tests/unit/test_gitlab_commit_status.py | 76 ++++++++++++++---------- 3 files changed, 103 insertions(+), 40 deletions(-) create mode 100644 instructions/gitlab-commit-status/uat.md diff --git a/instructions/gitlab-commit-status/uat.md b/instructions/gitlab-commit-status/uat.md new file mode 100644 index 0000000..30783b2 --- /dev/null +++ b/instructions/gitlab-commit-status/uat.md @@ -0,0 +1,46 @@ +# 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) + +## Configuring Protected Branch Requirement +1. Go to **Settings > Repository > Protected branches** +2. Edit the target branch +3. Under **Status checks**, add `socket-security` as a required external status check +4. MRs targeting that branch will now require Socket's `success` status to merge diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index f0a49c6..45a8cd4 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -261,11 +261,15 @@ def add_socket_comments( self.post_comment(security_comment) 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'.""" + """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 - path = f"projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}" + url = f"{self.config.api_url}/projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}" payload = { "state": state, "name": "socket-security", @@ -274,13 +278,12 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') if target_url: payload["target_url"] = target_url try: - self._request_with_fallback( - path=path, - payload=payload, - method="POST", - headers=self.config.headers, - base_url=self.config.api_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) + 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}") diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py index 4742a5a..cc5c86e 100644 --- a/tests/unit/test_gitlab_commit_status.py +++ b/tests/unit/test_gitlab_commit_status.py @@ -31,70 +31,84 @@ def _make_gitlab_config(**overrides): class TestSetCommitStatus: """Test Gitlab.set_commit_status()""" - def test_calls_correct_api_path(self): + @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() - client = MagicMock() - gl = Gitlab(client=client, config=config) - gl._request_with_fallback = MagicMock() + gl = Gitlab(client=MagicMock(), config=config) gl.set_commit_status("success", "No blocking issues", "https://app.socket.dev/report/123") - gl._request_with_fallback.assert_called_once_with( - path="projects/99/statuses/abc123def456", - payload={ + mock_post.assert_called_once_with( + "https://gitlab.example.com/api/v4/projects/99/statuses/abc123def456", + json={ "state": "success", "name": "socket-security", "description": "No blocking issues", "target_url": "https://app.socket.dev/report/123", }, - method="POST", headers=config.headers, - base_url=config.api_url, ) - def test_failed_state_payload(self): + @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() - client = MagicMock() - gl = Gitlab(client=client, config=config) - gl._request_with_fallback = MagicMock() + gl = Gitlab(client=MagicMock(), config=config) gl.set_commit_status("failed", "3 blocking alert(s) found") - args = gl._request_with_fallback.call_args - assert args.kwargs["payload"]["state"] == "failed" - assert args.kwargs["payload"]["description"] == "3 blocking alert(s) found" - assert "target_url" not in args.kwargs["payload"] + 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 - def test_skipped_when_no_mr_project_id(self): + @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) - client = MagicMock() - gl = Gitlab(client=client, config=config) - gl._request_with_fallback = MagicMock() + gl = Gitlab(client=MagicMock(), config=config) gl.set_commit_status("success", "No blocking issues") - gl._request_with_fallback.assert_not_called() + mock_post.assert_not_called() - def test_graceful_error_handling(self): + @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() - client = MagicMock() - gl = Gitlab(client=client, config=config) - gl._request_with_fallback = MagicMock(side_effect=Exception("API error")) + gl = Gitlab(client=MagicMock(), config=config) # Should not raise gl.set_commit_status("success", "No blocking issues") - def test_no_target_url_omitted_from_payload(self): + @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() - client = MagicMock() - gl = Gitlab(client=client, config=config) - gl._request_with_fallback = MagicMock() + gl = Gitlab(client=MagicMock(), config=config) gl.set_commit_status("success", "No blocking issues", target_url="") - payload = gl._request_with_fallback.call_args.kwargs["payload"] + 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 TestEnableCommitStatusCliArg: """Test --enable-commit-status CLI argument parsing""" From 76ed321689cfaab8575b3ad19e2760942ddb8081 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 15:23:10 -0800 Subject: [PATCH 3/9] fix: use source branch SHA for commit status, log response body CI_COMMIT_SHA may be synthetic in merged-results pipelines. Prefer CI_MERGE_REQUEST_SOURCE_BRANCH_SHA when available. Co-Authored-By: Claude Opus 4.6 --- socketsecurity/core/scm/gitlab.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 45a8cd4..5bf1f6c 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -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, @@ -278,11 +285,14 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') 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: From bd543312e2c75a6998bb6fcd872118327c19b2a4 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 15:36:15 -0800 Subject: [PATCH 4/9] fix: use context instead of name for commit status GitLab rejects duplicate name field; context allows updates. Co-Authored-By: Claude Opus 4.6 --- socketsecurity/core/scm/gitlab.py | 2 +- tests/unit/test_gitlab_commit_status.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 5bf1f6c..2972707 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -279,7 +279,7 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') url = f"{self.config.api_url}/projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}" payload = { "state": state, - "name": "socket-security", + "context": "socket-security", "description": description, } if target_url: diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py index cc5c86e..0c9d30b 100644 --- a/tests/unit/test_gitlab_commit_status.py +++ b/tests/unit/test_gitlab_commit_status.py @@ -43,7 +43,7 @@ def test_calls_correct_url_and_json_payload(self, mock_post): "https://gitlab.example.com/api/v4/projects/99/statuses/abc123def456", json={ "state": "success", - "name": "socket-security", + "context": "socket-security", "description": "No blocking issues", "target_url": "https://app.socket.dev/report/123", }, From fc0836456fc03c1c6371f6a6de27f41a51aa54f2 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 15:40:07 -0800 Subject: [PATCH 5/9] fix: add ref and pipeline_id to commit status payload GitLab uses (sha, name, ref) as unique key. Without ref, re-runs fail with "name has already been taken". Co-Authored-By: Claude Opus 4.6 --- socketsecurity/core/scm/gitlab.py | 5 +++++ tests/unit/test_gitlab_commit_status.py | 1 + 2 files changed, 6 insertions(+) diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 2972707..1ab48c1 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -282,6 +282,11 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') "context": "socket-security", "description": description, } + if self.config.mr_source_branch: + payload["ref"] = self.config.mr_source_branch + pipeline_id = os.getenv("CI_PIPELINE_ID") + if pipeline_id: + payload["pipeline_id"] = int(pipeline_id) if target_url: payload["target_url"] = target_url try: diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py index 0c9d30b..7ac81de 100644 --- a/tests/unit/test_gitlab_commit_status.py +++ b/tests/unit/test_gitlab_commit_status.py @@ -45,6 +45,7 @@ def test_calls_correct_url_and_json_payload(self, mock_post): "state": "success", "context": "socket-security", "description": "No blocking issues", + "ref": "feature", "target_url": "https://app.socket.dev/report/123", }, headers=config.headers, From db6f093ff6ca57738577e0a7949741b27eebe038 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 15:45:28 -0800 Subject: [PATCH 6/9] fix: drop pipeline_id from commit status payload pipeline_id causes 404 when sha/ref don't match the pipeline. ref alone is sufficient for uniqueness. Co-Authored-By: Claude Opus 4.6 --- socketsecurity/core/scm/gitlab.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 1ab48c1..9c2fe28 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -284,9 +284,6 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') } if self.config.mr_source_branch: payload["ref"] = self.config.mr_source_branch - pipeline_id = os.getenv("CI_PIPELINE_ID") - if pipeline_id: - payload["pipeline_id"] = int(pipeline_id) if target_url: payload["target_url"] = target_url try: From 8490466f02d19fcb08de78fc686d39daaf1ca7d2 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 16:06:52 -0800 Subject: [PATCH 7/9] renaming the commit status --- socketsecurity/core/scm/gitlab.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 9c2fe28..2c2cbf6 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -279,7 +279,7 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') url = f"{self.config.api_url}/projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}" payload = { "state": state, - "context": "socket-security", + "context": "socket-security-commit-status", "description": description, } if self.config.mr_source_branch: From dbaa989f420eed79ba6e79384b2708735ff53e85 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 5 Feb 2026 16:16:33 -0800 Subject: [PATCH 8/9] Add enable_merge_pipeline_check() to enforce pipelines-must-succeed via API Co-Authored-By: Claude Opus 4.6 --- instructions/gitlab-commit-status/uat.md | 18 +++++--- socketsecurity/core/scm/gitlab.py | 26 ++++++++++++ socketsecurity/socketcli.py | 1 + tests/unit/test_gitlab_commit_status.py | 53 +++++++++++++++++++++++- 4 files changed, 92 insertions(+), 6 deletions(-) diff --git a/instructions/gitlab-commit-status/uat.md b/instructions/gitlab-commit-status/uat.md index 30783b2..f3b62a8 100644 --- a/instructions/gitlab-commit-status/uat.md +++ b/instructions/gitlab-commit-status/uat.md @@ -39,8 +39,16 @@ 1. Run: `socketcli --scm github --enable-commit-status` 2. **Expected**: Flag is accepted but commit status is not posted (GitHub not yet supported) -## Configuring Protected Branch Requirement -1. Go to **Settings > Repository > Protected branches** -2. Edit the target branch -3. Under **Status checks**, add `socket-security` as a required external status check -4. MRs targeting that branch will now require Socket's `success` status to merge +## 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 diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 2c2cbf6..e88e050 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -267,6 +267,32 @@ 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'. diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 7c26954..f4ac6c8 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -645,6 +645,7 @@ def main_code(): 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) diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py index 7ac81de..fc57ed6 100644 --- a/tests/unit/test_gitlab_commit_status.py +++ b/tests/unit/test_gitlab_commit_status.py @@ -43,7 +43,7 @@ def test_calls_correct_url_and_json_payload(self, mock_post): "https://gitlab.example.com/api/v4/projects/99/statuses/abc123def456", json={ "state": "success", - "context": "socket-security", + "context": "socket-security-commit-status", "description": "No blocking issues", "ref": "feature", "target_url": "https://app.socket.dev/report/123", @@ -111,6 +111,57 @@ def test_auth_fallback_on_401(self, mock_post): 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""" From 0cbb75047f05fe79c2e83553ed3d439045bbbfe7 Mon Sep 17 00:00:00 2001 From: Jonathan Mucha Date: Thu, 12 Feb 2026 12:22:02 -0500 Subject: [PATCH 9/9] chore: update uv.lock version to 2.2.73 Co-Authored-By: Claude Opus 4.6 --- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- uv.lock | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index ce3bb6c..4b1a3b6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 7dd23bc..5993712 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.72' +__version__ = '2.2.73' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/uv.lock b/uv.lock index f8cbcd5..ce91d6d 100644 --- a/uv.lock +++ b/uv.lock @@ -1263,7 +1263,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.69" +version = "2.2.71" source = { editable = "." } dependencies = [ { name = "bs4" },