diff --git a/instructions/gitlab-commit-status/uat.md b/instructions/gitlab-commit-status/uat.md new file mode 100644 index 0000000..f3b62a8 --- /dev/null +++ b/instructions/gitlab-commit-status/uat.md @@ -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 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/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..e88e050 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, @@ -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: diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 194da44..f4ac6c8 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -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)) diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py new file mode 100644 index 0000000..fc57ed6 --- /dev/null +++ b/tests/unit/test_gitlab_commit_status.py @@ -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 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" },