Skip to content

Commit b3fdd33

Browse files
jonathanStrange0Jonathan Muchaclaudedacoburn
authored
Mucha/gitlab branch protection flag (#163)
* added ability to prevent merges based on failed check run * updated json format for gitlab api call * 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 <noreply@anthropic.com> * fix: use context instead of name for commit status GitLab rejects duplicate name field; context allows updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * renaming the commit status * Add enable_merge_pipeline_check() to enforce pipelines-must-succeed via API Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * chore: update uv.lock version to 2.2.73 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Jonathan Mucha <jonathan@mucha.local> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Douglas <douglas@socket.dev>
1 parent 78d66a4 commit b3fdd33

File tree

7 files changed

+336
-3
lines changed

7 files changed

+336
-3
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
# UAT: GitLab Commit Status Integration
2+
3+
## Feature
4+
`--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.
5+
6+
## Prerequisites
7+
- GitLab project with CI/CD configured
8+
- `GITLAB_TOKEN` with `api` scope (or `CI_JOB_TOKEN` with sufficient permissions)
9+
- Merge request pipeline (so `CI_MERGE_REQUEST_PROJECT_ID` is set)
10+
11+
## Test Cases
12+
13+
### 1. Pass scenario (no blocking alerts)
14+
1. Create MR with no dependency changes (or only safe ones)
15+
2. Run: `socketcli --scm gitlab --enable-commit-status`
16+
3. **Expected**: Commit status `socket-security` = `success`, description = "No blocking issues"
17+
4. Verify in GitLab: **Repository > Commits > (sha) > Pipelines** or **MR > Pipeline > External** tab
18+
19+
### 2. Fail scenario (blocking alerts)
20+
1. Create MR adding a package with known blocking alerts
21+
2. Run: `socketcli --scm gitlab --enable-commit-status`
22+
3. **Expected**: Commit status = `failed`, description = "N blocking alert(s) found"
23+
24+
### 3. Flag omitted (default off)
25+
1. Run: `socketcli --scm gitlab` (no `--enable-commit-status`)
26+
2. **Expected**: No commit status posted
27+
28+
### 4. Non-MR pipeline (push event without MR)
29+
1. Trigger pipeline on a push (no MR context)
30+
2. Run: `socketcli --scm gitlab --enable-commit-status`
31+
3. **Expected**: Commit status skipped (no `mr_project_id`), no error
32+
33+
### 5. API failure is non-fatal
34+
1. Use an invalid/revoked `GITLAB_TOKEN`
35+
2. Run: `socketcli --scm gitlab --enable-commit-status`
36+
3. **Expected**: Error logged ("Failed to set commit status: ..."), scan still completes with correct exit code
37+
38+
### 6. Non-GitLab SCM
39+
1. Run: `socketcli --scm github --enable-commit-status`
40+
2. **Expected**: Flag is accepted but commit status is not posted (GitHub not yet supported)
41+
42+
## Blocking Merges on Failure
43+
44+
### Option A: Pipelines must succeed (all GitLab tiers)
45+
Since `socketcli` exits with code 1 when blocking alerts are found, the pipeline fails automatically.
46+
1. Go to **Settings > General > Merge requests**
47+
2. Under **Merge checks**, enable **"Pipelines must succeed"**
48+
3. Save — GitLab will now prevent merging when the pipeline fails
49+
50+
### Option B: External status checks (GitLab Ultimate only)
51+
Use the `socket-security` commit status as a required external check.
52+
1. Go to **Settings > General > Merge requests > Status checks**
53+
2. Add an external status check with name `socket-security`
54+
3. MRs will require Socket's `success` status to merge

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.2.72"
9+
version = "2.2.73"
1010
requires-python = ">= 3.10"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.2.72'
2+
__version__ = '2.2.73'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class CliConfig:
8686
only_facts_file: bool = False
8787
reach_use_only_pregenerated_sboms: bool = False
8888
max_purl_batch_size: int = 5000
89+
enable_commit_status: bool = False
8990

9091
@classmethod
9192
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':
164165
'only_facts_file': args.only_facts_file,
165166
'reach_use_only_pregenerated_sboms': args.reach_use_only_pregenerated_sboms,
166167
'max_purl_batch_size': args.max_purl_batch_size,
168+
'enable_commit_status': args.enable_commit_status,
167169
'version': __version__
168170
}
169171
try:
@@ -512,6 +514,18 @@ def create_argument_parser() -> argparse.ArgumentParser:
512514
action="store_true",
513515
help=argparse.SUPPRESS
514516
)
517+
output_group.add_argument(
518+
"--enable-commit-status",
519+
dest="enable_commit_status",
520+
action="store_true",
521+
help="Report scan result as a commit status on GitLab (requires GitLab SCM)"
522+
)
523+
output_group.add_argument(
524+
"--enable_commit_status",
525+
dest="enable_commit_status",
526+
action="store_true",
527+
help=argparse.SUPPRESS
528+
)
515529

516530
# Plugin Configuration
517531
plugin_group = parser.add_argument_group('Plugin Configuration')

socketsecurity/core/scm/gitlab.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,15 @@ def from_env(cls) -> 'GitlabConfig':
4747
# Determine which authentication pattern to use
4848
headers = cls._get_auth_headers(token)
4949

50+
# Prefer source branch SHA (real commit) over CI_COMMIT_SHA which
51+
# may be a synthetic merge-result commit in merged-results pipelines.
52+
commit_sha = (
53+
os.getenv('CI_MERGE_REQUEST_SOURCE_BRANCH_SHA') or
54+
os.getenv('CI_COMMIT_SHA', '')
55+
)
56+
5057
return cls(
51-
commit_sha=os.getenv('CI_COMMIT_SHA', ''),
58+
commit_sha=commit_sha,
5259
api_url=os.getenv('CI_API_V4_URL', ''),
5360
project_dir=os.getenv('CI_PROJECT_DIR', ''),
5461
mr_source_branch=mr_source_branch,
@@ -260,6 +267,65 @@ def add_socket_comments(
260267
log.debug("No Previous version of Security Issue comment, posting")
261268
self.post_comment(security_comment)
262269

270+
def enable_merge_pipeline_check(self) -> None:
271+
"""Enable 'only_allow_merge_if_pipeline_succeeds' on the MR target project."""
272+
if not self.config.mr_project_id:
273+
return
274+
url = f"{self.config.api_url}/projects/{self.config.mr_project_id}"
275+
try:
276+
resp = requests.put(
277+
url,
278+
json={"only_allow_merge_if_pipeline_succeeds": True},
279+
headers=self.config.headers,
280+
)
281+
if resp.status_code == 401:
282+
fallback = self._get_fallback_headers(self.config.headers)
283+
if fallback:
284+
resp = requests.put(
285+
url,
286+
json={"only_allow_merge_if_pipeline_succeeds": True},
287+
headers=fallback,
288+
)
289+
if resp.status_code >= 400:
290+
log.error(f"GitLab enable merge check API {resp.status_code}: {resp.text}")
291+
else:
292+
log.info("Enabled 'pipelines must succeed' merge check on project")
293+
except Exception as e:
294+
log.error(f"Failed to enable merge pipeline check: {e}")
295+
296+
def set_commit_status(self, state: str, description: str, target_url: str = '') -> None:
297+
"""Post a commit status to GitLab. state should be 'success' or 'failed'.
298+
299+
Uses requests.post with json= directly because CliClient.request sends
300+
data= (form-encoded) which GitLab's commit status endpoint rejects.
301+
"""
302+
if not self.config.mr_project_id:
303+
log.debug("No mr_project_id, skipping commit status")
304+
return
305+
url = f"{self.config.api_url}/projects/{self.config.mr_project_id}/statuses/{self.config.commit_sha}"
306+
payload = {
307+
"state": state,
308+
"context": "socket-security-commit-status",
309+
"description": description,
310+
}
311+
if self.config.mr_source_branch:
312+
payload["ref"] = self.config.mr_source_branch
313+
if target_url:
314+
payload["target_url"] = target_url
315+
try:
316+
log.debug(f"Posting commit status to {url}")
317+
resp = requests.post(url, json=payload, headers=self.config.headers)
318+
if resp.status_code == 401:
319+
fallback = self._get_fallback_headers(self.config.headers)
320+
if fallback:
321+
resp = requests.post(url, json=payload, headers=fallback)
322+
if resp.status_code >= 400:
323+
log.error(f"GitLab commit status API {resp.status_code}: {resp.text}")
324+
resp.raise_for_status()
325+
log.info(f"Commit status set to '{state}' on {self.config.commit_sha[:8]}")
326+
except Exception as e:
327+
log.error(f"Failed to set commit status: {e}")
328+
263329
def remove_comment_alerts(self, comments: dict):
264330
security_alert = comments.get("security")
265331
if security_alert is not None:

socketsecurity/socketcli.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,21 @@ def main_code():
641641
log.debug("Temporarily enabling disable_blocking due to no supported manifest files")
642642
config.disable_blocking = True
643643

644+
# Post commit status to GitLab if enabled
645+
if config.enable_commit_status and scm is not None:
646+
from socketsecurity.core.scm.gitlab import Gitlab
647+
if isinstance(scm, Gitlab) and scm.config.mr_project_id:
648+
scm.enable_merge_pipeline_check()
649+
passed = output_handler.report_pass(diff)
650+
state = "success" if passed else "failed"
651+
blocking_count = sum(1 for a in diff.new_alerts if a.error)
652+
if passed:
653+
description = "No blocking issues"
654+
else:
655+
description = f"{blocking_count} blocking alert(s) found"
656+
target_url = diff.report_url or diff.diff_url or ""
657+
scm.set_commit_status(state, description, target_url)
658+
644659
sys.exit(output_handler.return_exit_code(diff))
645660

646661

Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
"""Tests for GitLab commit status integration"""
2+
import os
3+
import pytest
4+
from unittest.mock import patch, MagicMock, call
5+
6+
from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig
7+
8+
9+
def _make_gitlab_config(**overrides):
10+
defaults = dict(
11+
commit_sha="abc123def456",
12+
api_url="https://gitlab.example.com/api/v4",
13+
project_dir="/builds/test",
14+
mr_source_branch="feature",
15+
mr_iid="42",
16+
mr_project_id="99",
17+
commit_message="test commit",
18+
default_branch="main",
19+
project_name="test-project",
20+
pipeline_source="merge_request_event",
21+
commit_author="dev@example.com",
22+
token="glpat-test",
23+
repository="test-project",
24+
is_default_branch=False,
25+
headers={"Authorization": "Bearer glpat-test", "accept": "application/json"},
26+
)
27+
defaults.update(overrides)
28+
return GitlabConfig(**defaults)
29+
30+
31+
class TestSetCommitStatus:
32+
"""Test Gitlab.set_commit_status()"""
33+
34+
@patch("socketsecurity.core.scm.gitlab.requests.post")
35+
def test_calls_correct_url_and_json_payload(self, mock_post):
36+
mock_post.return_value = MagicMock(status_code=200)
37+
config = _make_gitlab_config()
38+
gl = Gitlab(client=MagicMock(), config=config)
39+
40+
gl.set_commit_status("success", "No blocking issues", "https://app.socket.dev/report/123")
41+
42+
mock_post.assert_called_once_with(
43+
"https://gitlab.example.com/api/v4/projects/99/statuses/abc123def456",
44+
json={
45+
"state": "success",
46+
"context": "socket-security-commit-status",
47+
"description": "No blocking issues",
48+
"ref": "feature",
49+
"target_url": "https://app.socket.dev/report/123",
50+
},
51+
headers=config.headers,
52+
)
53+
54+
@patch("socketsecurity.core.scm.gitlab.requests.post")
55+
def test_failed_state_payload(self, mock_post):
56+
mock_post.return_value = MagicMock(status_code=200)
57+
config = _make_gitlab_config()
58+
gl = Gitlab(client=MagicMock(), config=config)
59+
60+
gl.set_commit_status("failed", "3 blocking alert(s) found")
61+
62+
payload = mock_post.call_args.kwargs["json"]
63+
assert payload["state"] == "failed"
64+
assert payload["description"] == "3 blocking alert(s) found"
65+
assert "target_url" not in payload
66+
67+
@patch("socketsecurity.core.scm.gitlab.requests.post")
68+
def test_skipped_when_no_mr_project_id(self, mock_post):
69+
config = _make_gitlab_config(mr_project_id=None)
70+
gl = Gitlab(client=MagicMock(), config=config)
71+
72+
gl.set_commit_status("success", "No blocking issues")
73+
74+
mock_post.assert_not_called()
75+
76+
@patch("socketsecurity.core.scm.gitlab.requests.post")
77+
def test_graceful_error_handling(self, mock_post):
78+
mock_post.side_effect = Exception("connection error")
79+
config = _make_gitlab_config()
80+
gl = Gitlab(client=MagicMock(), config=config)
81+
82+
# Should not raise
83+
gl.set_commit_status("success", "No blocking issues")
84+
85+
@patch("socketsecurity.core.scm.gitlab.requests.post")
86+
def test_no_target_url_omitted_from_payload(self, mock_post):
87+
mock_post.return_value = MagicMock(status_code=200)
88+
config = _make_gitlab_config()
89+
gl = Gitlab(client=MagicMock(), config=config)
90+
91+
gl.set_commit_status("success", "No blocking issues", target_url="")
92+
93+
payload = mock_post.call_args.kwargs["json"]
94+
assert "target_url" not in payload
95+
96+
@patch("socketsecurity.core.scm.gitlab.requests.post")
97+
def test_auth_fallback_on_401(self, mock_post):
98+
resp_401 = MagicMock(status_code=401)
99+
resp_401.raise_for_status.side_effect = Exception("401")
100+
resp_200 = MagicMock(status_code=200)
101+
mock_post.side_effect = [resp_401, resp_200]
102+
103+
config = _make_gitlab_config()
104+
gl = Gitlab(client=MagicMock(), config=config)
105+
106+
gl.set_commit_status("success", "No blocking issues")
107+
108+
assert mock_post.call_count == 2
109+
# Second call should use fallback headers (PRIVATE-TOKEN)
110+
fallback_headers = mock_post.call_args_list[1].kwargs["headers"]
111+
assert "PRIVATE-TOKEN" in fallback_headers
112+
113+
114+
class TestEnableMergePipelineCheck:
115+
"""Test Gitlab.enable_merge_pipeline_check()"""
116+
117+
@patch("socketsecurity.core.scm.gitlab.requests.put")
118+
def test_calls_correct_url_and_payload(self, mock_put):
119+
mock_put.return_value = MagicMock(status_code=200)
120+
config = _make_gitlab_config()
121+
gl = Gitlab(client=MagicMock(), config=config)
122+
123+
gl.enable_merge_pipeline_check()
124+
125+
mock_put.assert_called_once_with(
126+
"https://gitlab.example.com/api/v4/projects/99",
127+
json={"only_allow_merge_if_pipeline_succeeds": True},
128+
headers=config.headers,
129+
)
130+
131+
@patch("socketsecurity.core.scm.gitlab.requests.put")
132+
def test_skipped_when_no_mr_project_id(self, mock_put):
133+
config = _make_gitlab_config(mr_project_id=None)
134+
gl = Gitlab(client=MagicMock(), config=config)
135+
136+
gl.enable_merge_pipeline_check()
137+
138+
mock_put.assert_not_called()
139+
140+
@patch("socketsecurity.core.scm.gitlab.requests.put")
141+
def test_auth_fallback_on_401(self, mock_put):
142+
resp_401 = MagicMock(status_code=401)
143+
resp_200 = MagicMock(status_code=200)
144+
mock_put.side_effect = [resp_401, resp_200]
145+
146+
config = _make_gitlab_config()
147+
gl = Gitlab(client=MagicMock(), config=config)
148+
149+
gl.enable_merge_pipeline_check()
150+
151+
assert mock_put.call_count == 2
152+
fallback_headers = mock_put.call_args_list[1].kwargs["headers"]
153+
assert "PRIVATE-TOKEN" in fallback_headers
154+
155+
@patch("socketsecurity.core.scm.gitlab.requests.put")
156+
def test_graceful_error_handling(self, mock_put):
157+
mock_put.side_effect = Exception("connection error")
158+
config = _make_gitlab_config()
159+
gl = Gitlab(client=MagicMock(), config=config)
160+
161+
# Should not raise
162+
gl.enable_merge_pipeline_check()
163+
164+
165+
class TestEnableCommitStatusCliArg:
166+
"""Test --enable-commit-status CLI argument parsing"""
167+
168+
def test_default_is_false(self):
169+
from socketsecurity.config import create_argument_parser
170+
parser = create_argument_parser()
171+
args = parser.parse_args([])
172+
assert args.enable_commit_status is False
173+
174+
def test_flag_sets_true(self):
175+
from socketsecurity.config import create_argument_parser
176+
parser = create_argument_parser()
177+
args = parser.parse_args(["--enable-commit-status"])
178+
assert args.enable_commit_status is True
179+
180+
def test_underscore_alias(self):
181+
from socketsecurity.config import create_argument_parser
182+
parser = create_argument_parser()
183+
args = parser.parse_args(["--enable_commit_status"])
184+
assert args.enable_commit_status is True

0 commit comments

Comments
 (0)