diff --git a/pyproject.toml b/pyproject.toml index 81b9d87..466d25f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.90" +version = "2.2.92" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 90049de..9f58d90 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.90' +__version__ = '2.2.92' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/scm/bitbucket.py b/socketsecurity/core/scm/bitbucket.py new file mode 100644 index 0000000..f464c9a --- /dev/null +++ b/socketsecurity/core/scm/bitbucket.py @@ -0,0 +1,352 @@ +import base64 +import json +import os +import sys +from dataclasses import dataclass +from typing import Optional +from urllib.parse import urlparse + +from socketsecurity import USER_AGENT +from socketsecurity.core import log +from socketsecurity.core.classes import Comment +from socketsecurity.core.scm_comments import Comments +from socketsecurity.socketcli import CliClient + + +@dataclass +class BitbucketConfig: + """Configuration from Bitbucket Pipelines environment variables.""" + api_url: str + workspace: str + repo_slug: str + repository: str + pr_id: Optional[str] + source_branch: Optional[str] + destination_branch: Optional[str] + default_branch: Optional[str] + commit_sha: str + is_default_branch: bool + token: str + username: Optional[str] + headers: dict + + @classmethod + def from_env(cls, pr_number: Optional[str] = None) -> "BitbucketConfig": + """Create config from Bitbucket Pipelines env vars. + + Supports two auth styles: + - Bearer: BITBUCKET_TOKEN (workspace/repo/project access tokens, OAuth) + - Basic: BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD + """ + token = os.getenv("BITBUCKET_TOKEN", "") + username = os.getenv("BITBUCKET_USERNAME") + app_password = os.getenv("BITBUCKET_APP_PASSWORD") + + if not token and not (username and app_password): + log.error( + "Unable to get Bitbucket credentials. Set BITBUCKET_TOKEN, " + "or BITBUCKET_USERNAME + BITBUCKET_APP_PASSWORD." + ) + sys.exit(2) + + api_url = os.getenv("BITBUCKET_API_URL", "https://api.bitbucket.org/2.0").rstrip("/") + + repo_full_name = os.getenv("BITBUCKET_REPO_FULL_NAME", "") + workspace = os.getenv("BITBUCKET_WORKSPACE", "") + repo_slug = os.getenv("BITBUCKET_REPO_SLUG", "") + if repo_full_name and "/" in repo_full_name: + full_workspace, full_slug = repo_full_name.split("/", 1) + workspace = workspace or full_workspace + repo_slug = repo_slug or full_slug + + if not workspace or not repo_slug: + log.error( + "Unable to determine Bitbucket workspace/repo. Set " + "BITBUCKET_REPO_FULL_NAME, or BITBUCKET_WORKSPACE + " + "BITBUCKET_REPO_SLUG." + ) + sys.exit(2) + + pr_id = pr_number or os.getenv("BITBUCKET_PR_ID") + if pr_id == "0": + pr_id = None + + source_branch = os.getenv("BITBUCKET_BRANCH") + destination_branch = os.getenv("BITBUCKET_PR_DESTINATION_BRANCH") + default_branch = os.getenv("BITBUCKET_DEFAULT_BRANCH") + commit_sha = os.getenv("BITBUCKET_COMMIT", "") + + is_default_branch = bool( + source_branch and default_branch and source_branch == default_branch + ) + + headers = cls._get_auth_headers(token, username, app_password) + + return cls( + api_url=api_url, + workspace=workspace, + repo_slug=repo_slug, + repository=repo_slug, + pr_id=pr_id, + source_branch=source_branch, + destination_branch=destination_branch, + default_branch=default_branch, + commit_sha=commit_sha, + is_default_branch=is_default_branch, + token=token, + username=username, + headers=headers, + ) + + @staticmethod + def _get_auth_headers( + token: str, + username: Optional[str], + app_password: Optional[str], + ) -> dict: + base_headers = { + "User-Agent": USER_AGENT, + "Accept": "application/json", + "Content-Type": "application/json", + } + if token: + return {**base_headers, "Authorization": f"Bearer {token}"} + encoded = base64.b64encode(f"{username}:{app_password}".encode()).decode("ascii") + return {**base_headers, "Authorization": f"Basic {encoded}"} + + +class Bitbucket: + PROCESSED_MARKER = "" + + # No Bearer/Basic fallback retry (cf. Gitlab._request_with_fallback) because + # Bitbucket's auth scheme is unambiguous: BITBUCKET_TOKEN selects Bearer, + # BITBUCKET_USERNAME+BITBUCKET_APP_PASSWORD selects Basic. If both routes + # fail, the credential itself is wrong, not the scheme. + + def __init__(self, client: CliClient, config: Optional[BitbucketConfig] = None): + self.config = config or BitbucketConfig.from_env() + self.client = client + # Populated by get_comments_for_pr; consulted by has_thumbsup_reaction + # to avoid one extra GET per ignore comment when the body is already + # in memory. + self._comment_body_cache: dict = {} + + @staticmethod + def _split_absolute_url(url: str) -> tuple[str, str]: + """Split an absolute URL into (origin, path+query) for CliClient.request. + + CliClient builds URLs as f"{base_url}/{path}", so an empty base_url + would fall back to Socket's API URL. To request a Bitbucket-absolute + URL (like the 'next' link in paginated responses), we hand the origin + in as base_url and the path/query as path. + """ + parsed = urlparse(url) + origin = f"{parsed.scheme}://{parsed.netloc}" + path = parsed.path.lstrip("/") + if parsed.query: + path = f"{path}?{parsed.query}" + return origin, path + + def check_event_type(self) -> str: + """Bitbucket Pipelines does not expose a 'comment' trigger. + + If a PR id is set we treat the run as a diff; otherwise main branch. + """ + if self.config.pr_id: + return "diff" + return "main" + + def _pr_comments_path(self, comment_id: Optional[str] = None) -> str: + base = ( + f"repositories/{self.config.workspace}/{self.config.repo_slug}" + f"/pullrequests/{self.config.pr_id}/comments" + ) + if comment_id: + return f"{base}/{comment_id}" + return base + + def post_comment(self, body: str) -> None: + path = self._pr_comments_path() + payload = json.dumps({"content": {"raw": body}}) + self.client.request( + path=path, + payload=payload, + method="POST", + headers=self.config.headers, + base_url=self.config.api_url, + ) + + def update_comment(self, body: str, comment_id: str) -> None: + path = self._pr_comments_path(comment_id) + payload = json.dumps({"content": {"raw": body}}) + self.client.request( + path=path, + payload=payload, + method="PUT", + headers=self.config.headers, + base_url=self.config.api_url, + ) + + def get_comments_for_pr(self) -> dict: + log.debug( + f"Getting Bitbucket comments for Repo {self.config.repo_slug} " + f"for PR {self.config.pr_id}" + ) + comments: dict = {} + if not self.config.pr_id: + return comments + + next_url: Optional[str] = None + first_path = f"{self._pr_comments_path()}?pagelen=100" + + while True: + if next_url: + # Bitbucket returns absolute 'next' URLs; split origin off so + # CliClient doesn't prepend the Socket API base. + origin, abs_path = self._split_absolute_url(next_url) + response = self.client.request( + path=abs_path, + headers=self.config.headers, + base_url=origin, + ) + else: + response = self.client.request( + path=first_path, + headers=self.config.headers, + base_url=self.config.api_url, + ) + data = Comments.process_response(response) + if not isinstance(data, dict): + log.error(f"Unexpected Bitbucket comments response: {data}") + break + if data.get("type") == "error" or "error" in data: + log.error(data) + break + + for raw in data.get("values") or []: + normalized = self._normalize_comment(raw) + if normalized is None: + continue + comment = Comment(**normalized) + comment.body_list = comment.body.split("\n") + comments[comment.id] = comment + self._comment_body_cache[comment.id] = comment.body + + next_url = data.get("next") + if not next_url: + break + + return Comments.check_for_socket_comments(comments) + + @staticmethod + def _normalize_comment(raw: dict) -> Optional[dict]: + """Map a Bitbucket Cloud comment payload to the Comment shape.""" + if not isinstance(raw, dict): + return None + if raw.get("deleted"): + return None + content = raw.get("content") or {} + # Bitbucket Cloud's `markup` field is the markup type ("markdown"), + # not body text; `html` is the rendered fallback for HTML-only edges. + body = content.get("raw") or content.get("html") or "" + user = raw.get("user") or {} + normalized_user = { + "login": user.get("nickname") or user.get("display_name", ""), + "username": user.get("nickname") or user.get("display_name", ""), + "id": user.get("uuid", ""), + "display_name": user.get("display_name", ""), + } + return { + "id": raw.get("id"), + "body": body, + "user": normalized_user, + "created_at": raw.get("created_on", ""), + "updated_at": raw.get("updated_on", ""), + "html_url": (raw.get("links") or {}).get("html", {}).get("href", ""), + "url": (raw.get("links") or {}).get("self", {}).get("href", ""), + "reactions": {}, + } + + def add_socket_comments( + self, + security_comment: str, + overview_comment: str, + comments: dict, + new_security_comment: bool = True, + new_overview_comment: bool = True, + ) -> None: + if not self.config.pr_id: + log.debug("No Bitbucket PR id, skipping comment posting") + return + + if new_overview_comment: + log.debug("New Dependency Overview comment") + if overview := comments.get("overview"): + log.debug("Previous version of Dependency Overview, updating") + self.update_comment(overview_comment, str(overview.id)) + else: + log.debug("No previous version of Dependency Overview, posting") + self.post_comment(overview_comment) + + if new_security_comment: + log.debug("New Security Issue Comment") + if security := comments.get("security"): + log.debug("Previous version of Security Issue comment, updating") + self.update_comment(security_comment, str(security.id)) + else: + log.debug("No previous version of Security Issue comment, posting") + self.post_comment(security_comment) + + def handle_ignore_reactions(self, comments: dict) -> None: + """Bitbucket Cloud comments have no native reactions API equivalent. + + We mark ignore comments as processed by editing them to append a + hidden Socket marker. Subsequent runs check for this marker via + has_thumbsup_reaction(). + """ + for comment in comments.get("ignore", []): + if "SocketSecurity ignore" in comment.body and not self.has_thumbsup_reaction(comment.id): + self._mark_comment_processed(comment) + + def has_thumbsup_reaction(self, comment_id) -> bool: + """Bitbucket has no reactions; detect our hidden processed marker. + + Prefers the in-memory body cache populated by get_comments_for_pr; + only falls back to a GET when called for an id we haven't loaded + (defensive — currently no call path does this). + """ + cached_body = self._comment_body_cache.get(comment_id) + if cached_body is not None: + return self.PROCESSED_MARKER in cached_body + + if not self.config.pr_id: + return False + try: + response = self.client.request( + path=self._pr_comments_path(str(comment_id)), + headers=self.config.headers, + base_url=self.config.api_url, + ) + data = response.json() or {} + body = (data.get("content") or {}).get("raw", "") + return self.PROCESSED_MARKER in body + except Exception as error: + log.debug(f"Could not fetch Bitbucket comment {comment_id} for marker check: {error}") + return False + + def _mark_comment_processed(self, comment) -> None: + if self.PROCESSED_MARKER in comment.body: + return + new_body = f"{comment.body}\n\n{self.PROCESSED_MARKER}" + try: + self.update_comment(new_body, str(comment.id)) + comment.body = new_body + self._comment_body_cache[comment.id] = new_body + except Exception as error: + log.debug(f"Failed to mark Bitbucket ignore comment {comment.id} as processed: {error}") + + def remove_comment_alerts(self, comments: dict) -> None: + if security_alert := comments.get("security"): + new_body = Comments.process_security_comment(security_alert, comments) + self.handle_ignore_reactions(comments) + self.update_comment(new_body, str(security_alert.id)) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 1f2b166..484ee8b 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -349,6 +349,11 @@ def main_code(): from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig gitlab_config = GitlabConfig.from_env() scm = Gitlab(client=client, config=gitlab_config) + elif config.scm == 'bitbucket': + from socketsecurity.core.scm.bitbucket import Bitbucket, BitbucketConfig + pr_number = config.pr_number if config.pr_number != "0" else None + bitbucket_config = BitbucketConfig.from_env(pr_number=pr_number) + scm = Bitbucket(client=client, config=bitbucket_config) # Don't override config.default_branch if it was explicitly set via --default-branch flag # Only use SCM detection if --default-branch wasn't provided if scm is not None and not config.default_branch: diff --git a/tests/unit/test_bitbucket.py b/tests/unit/test_bitbucket.py new file mode 100644 index 0000000..6d3fd34 --- /dev/null +++ b/tests/unit/test_bitbucket.py @@ -0,0 +1,461 @@ +"""Tests for Bitbucket SCM support.""" +import base64 +import json +import os +from unittest.mock import MagicMock, patch + +import pytest + +from socketsecurity.core.scm.bitbucket import Bitbucket, BitbucketConfig + + +def _make_config(pr_id="42", api_url="https://api.bitbucket.org/2.0"): + return BitbucketConfig( + api_url=api_url, + workspace="acme", + repo_slug="widgets", + repository="widgets", + pr_id=pr_id, + source_branch="feature", + destination_branch="main", + default_branch="main", + commit_sha="abc", + is_default_branch=False, + token="t", + username=None, + headers={"Authorization": "Bearer t", "Content-Type": "application/json"}, + ) + + +def _json_response(payload): + resp = MagicMock() + resp.json.return_value = payload + return resp + + +class TestBitbucketConfigFromEnv: + @patch.dict(os.environ, { + "BITBUCKET_TOKEN": "bbtoken-xyz", + "BITBUCKET_REPO_FULL_NAME": "acme/widgets", + "BITBUCKET_PR_ID": "42", + "BITBUCKET_BRANCH": "feature/x", + "BITBUCKET_PR_DESTINATION_BRANCH": "main", + "BITBUCKET_COMMIT": "deadbeef", + }, clear=True) + def test_from_env_with_token_uses_bearer(self): + config = BitbucketConfig.from_env() + assert config.workspace == "acme" + assert config.repo_slug == "widgets" + assert config.pr_id == "42" + assert config.source_branch == "feature/x" + assert config.destination_branch == "main" + assert config.commit_sha == "deadbeef" + assert config.headers["Authorization"] == "Bearer bbtoken-xyz" + assert config.headers["Content-Type"] == "application/json" + assert config.api_url == "https://api.bitbucket.org/2.0" + + @patch.dict(os.environ, { + "BITBUCKET_USERNAME": "alice", + "BITBUCKET_APP_PASSWORD": "secret", + "BITBUCKET_WORKSPACE": "acme", + "BITBUCKET_REPO_SLUG": "widgets", + }, clear=True) + def test_from_env_falls_back_to_basic_auth(self): + config = BitbucketConfig.from_env() + expected = base64.b64encode(b"alice:secret").decode("ascii") + assert config.headers["Authorization"] == f"Basic {expected}" + + @patch.dict(os.environ, {}, clear=True) + def test_from_env_missing_credentials_exits(self): + with pytest.raises(SystemExit): + BitbucketConfig.from_env() + + @patch.dict(os.environ, {"BITBUCKET_TOKEN": "t"}, clear=True) + def test_from_env_missing_workspace_repo_exits(self): + """Credentials present but no workspace/repo info — fail fast rather + than building 404-bound URLs deeper in the request flow.""" + with pytest.raises(SystemExit): + BitbucketConfig.from_env() + + @patch.dict(os.environ, { + "BITBUCKET_TOKEN": "t", + "BITBUCKET_REPO_FULL_NAME": "acme/widgets", + "BITBUCKET_BRANCH": "main", + "BITBUCKET_DEFAULT_BRANCH": "main", + }, clear=True) + def test_default_branch_detected(self): + config = BitbucketConfig.from_env() + assert config.is_default_branch is True + + @patch.dict(os.environ, { + "BITBUCKET_TOKEN": "t", + "BITBUCKET_REPO_FULL_NAME": "acme/widgets", + }, clear=True) + def test_pr_number_override(self): + config = BitbucketConfig.from_env(pr_number="99") + assert config.pr_id == "99" + + +class TestBitbucketCommentNormalization: + def test_normalize_comment_extracts_raw_content(self): + raw = { + "id": 1234, + "content": {"raw": "hello world", "markup": "markdown"}, + "user": {"display_name": "Alice", "nickname": "alice", "uuid": "{u-1}"}, + "created_on": "2024-01-01T00:00:00Z", + "updated_on": "2024-01-02T00:00:00Z", + "links": {"html": {"href": "https://example.com/c/1"}}, + } + normalized = Bitbucket._normalize_comment(raw) + assert normalized["id"] == 1234 + assert normalized["body"] == "hello world" + assert normalized["user"]["login"] == "alice" + assert normalized["html_url"] == "https://example.com/c/1" + + def test_normalize_skips_deleted(self): + assert Bitbucket._normalize_comment({"id": 1, "deleted": True}) is None + + def test_normalize_handles_missing_content(self): + normalized = Bitbucket._normalize_comment({"id": 7}) + assert normalized["body"] == "" + assert normalized["id"] == 7 + + +class TestBitbucketEventDetection: + def _make(self, **overrides): + cfg = BitbucketConfig( + api_url="https://api.bitbucket.org/2.0", + workspace="acme", + repo_slug="widgets", + repository="widgets", + pr_id=overrides.get("pr_id"), + source_branch="feature", + destination_branch="main", + default_branch="main", + commit_sha="abc", + is_default_branch=False, + token="t", + username=None, + headers={}, + ) + return Bitbucket(client=MagicMock(), config=cfg) + + def test_check_event_type_diff_when_pr(self): + scm = self._make(pr_id="5") + assert scm.check_event_type() == "diff" + + def test_check_event_type_main_when_no_pr(self): + scm = self._make(pr_id=None) + assert scm.check_event_type() == "main" + + +class TestBitbucketCommentPosting: + def _make_scm(self, pr_id="42"): + cfg = BitbucketConfig( + api_url="https://api.bitbucket.org/2.0", + workspace="acme", + repo_slug="widgets", + repository="widgets", + pr_id=pr_id, + source_branch="feature", + destination_branch="main", + default_branch="main", + commit_sha="abc", + is_default_branch=False, + token="t", + username=None, + headers={"Authorization": "Bearer t", "Content-Type": "application/json"}, + ) + client = MagicMock() + return Bitbucket(client=client, config=cfg), client + + def test_post_comment_sends_json_content_raw(self): + scm, client = self._make_scm() + scm.post_comment("hello") + call = client.request.call_args + assert call.kwargs["method"] == "POST" + assert call.kwargs["path"] == ( + "repositories/acme/widgets/pullrequests/42/comments" + ) + assert call.kwargs["base_url"] == "https://api.bitbucket.org/2.0" + assert json.loads(call.kwargs["payload"]) == {"content": {"raw": "hello"}} + assert call.kwargs["headers"]["Content-Type"] == "application/json" + + def test_update_comment_uses_put_with_id(self): + scm, client = self._make_scm() + scm.update_comment("updated", "777") + call = client.request.call_args + assert call.kwargs["method"] == "PUT" + assert call.kwargs["path"].endswith("/comments/777") + assert json.loads(call.kwargs["payload"]) == {"content": {"raw": "updated"}} + + def test_add_socket_comments_creates_when_no_previous(self): + scm, client = self._make_scm() + scm.add_socket_comments( + security_comment="security body", + overview_comment="overview body", + comments={}, + new_security_comment=True, + new_overview_comment=True, + ) + # Two POSTs (overview + security), no PUTs + methods = [c.kwargs["method"] for c in client.request.call_args_list] + assert methods.count("POST") == 2 + assert "PUT" not in methods + + def test_add_socket_comments_updates_existing(self): + scm, client = self._make_scm() + existing_overview = MagicMock(id=11) + existing_security = MagicMock(id=22) + scm.add_socket_comments( + security_comment="security body", + overview_comment="overview body", + comments={"overview": existing_overview, "security": existing_security}, + new_security_comment=True, + new_overview_comment=True, + ) + methods = [c.kwargs["method"] for c in client.request.call_args_list] + assert methods.count("PUT") == 2 + assert "POST" not in methods + + def test_add_socket_comments_no_pr_is_noop(self): + scm, client = self._make_scm(pr_id=None) + scm.add_socket_comments("s", "o", {}) + client.request.assert_not_called() + + +class TestSplitAbsoluteUrl: + def test_splits_origin_and_path(self): + origin, path = Bitbucket._split_absolute_url( + "https://api.bitbucket.org/2.0/repositories/acme/widgets/pullrequests/42/comments?page=2" + ) + assert origin == "https://api.bitbucket.org" + assert path == "2.0/repositories/acme/widgets/pullrequests/42/comments?page=2" + + def test_no_query_string(self): + origin, path = Bitbucket._split_absolute_url( + "https://bitbucket.example.com/rest/api/1.0/foo" + ) + assert origin == "https://bitbucket.example.com" + assert path == "rest/api/1.0/foo" + + def test_reconstructed_url_matches_clicliclient_join(self): + """CliClient does f'{base_url}/{path}' — verify our split round-trips.""" + original = "https://api.bitbucket.org/2.0/foo/bar?x=1&y=2" + origin, path = Bitbucket._split_absolute_url(original) + assert f"{origin}/{path}" == original + + +class TestBitbucketPagination: + def test_follows_next_url_via_origin_split(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + # Use Socket-style bodies so check_for_socket_comments keeps them and + # we can observe that BOTH pages were fetched. + page1 = { + "values": [ + { + "id": 1, + "content": {"raw": "socket-security-comment-actions: page1"}, + "user": {"nickname": "alice", "uuid": "{u-1}"}, + } + ], + "next": ( + "https://api.bitbucket.org/2.0/repositories/acme/widgets" + "/pullrequests/42/comments?page=2" + ), + } + page2 = { + "values": [ + { + "id": 2, + "content": {"raw": "socket-overview-comment-actions: page2"}, + "user": {"nickname": "bob", "uuid": "{u-2}"}, + } + ], + } + scm.client.request.side_effect = [_json_response(page1), _json_response(page2)] + + result = scm.get_comments_for_pr() + + # Both pages were scanned: the security comment came from page 1 and + # the overview comment from page 2. + assert "security" in result and result["security"].body.endswith("page1") + assert "overview" in result and result["overview"].body.endswith("page2") + + # First call: relative path against Bitbucket API base. + first_call = scm.client.request.call_args_list[0] + assert first_call.kwargs["base_url"] == "https://api.bitbucket.org/2.0" + assert "pagelen=100" in first_call.kwargs["path"] + + # Second call: origin pulled out of the absolute next URL — must NOT + # be empty (which would fall back to Socket's API URL in CliClient). + second_call = scm.client.request.call_args_list[1] + assert second_call.kwargs["base_url"] == "https://api.bitbucket.org" + assert second_call.kwargs["base_url"] # non-empty -> avoids fallback + assert second_call.kwargs["path"].startswith( + "2.0/repositories/acme/widgets/pullrequests/42/comments" + ) + assert "page=2" in second_call.kwargs["path"] + + def test_stops_when_no_next(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response({"values": []}) + scm.get_comments_for_pr() + assert scm.client.request.call_count == 1 + + def test_no_pr_skips_fetch(self): + scm = Bitbucket(client=MagicMock(), config=_make_config(pr_id=None)) + result = scm.get_comments_for_pr() + assert result == {} + scm.client.request.assert_not_called() + + def test_error_response_breaks_loop(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"type": "error", "error": {"message": "boom"}} + ) + result = scm.get_comments_for_pr() + assert result == {} + assert scm.client.request.call_count == 1 + + +class TestHasThumbsupReaction: + def test_detects_processed_marker(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"content": {"raw": f"some body\n\n{Bitbucket.PROCESSED_MARKER}"}} + ) + assert scm.has_thumbsup_reaction(123) is True + + def test_returns_false_when_marker_absent(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"content": {"raw": "plain comment with no marker"}} + ) + assert scm.has_thumbsup_reaction(123) is False + + def test_returns_false_when_no_pr(self): + scm = Bitbucket(client=MagicMock(), config=_make_config(pr_id=None)) + assert scm.has_thumbsup_reaction(123) is False + scm.client.request.assert_not_called() + + def test_returns_false_on_request_exception(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.side_effect = RuntimeError("network down") + assert scm.has_thumbsup_reaction(123) is False + + +class TestMarkCommentProcessed: + def test_appends_marker_and_updates(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + comment = MagicMock(id=99, body="original body") + scm._mark_comment_processed(comment) + call = scm.client.request.call_args + assert call.kwargs["method"] == "PUT" + payload = json.loads(call.kwargs["payload"]) + assert payload["content"]["raw"].startswith("original body") + assert Bitbucket.PROCESSED_MARKER in payload["content"]["raw"] + + def test_is_idempotent_when_marker_already_present(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + comment = MagicMock( + id=99, body=f"already processed\n\n{Bitbucket.PROCESSED_MARKER}" + ) + scm._mark_comment_processed(comment) + scm.client.request.assert_not_called() + + def test_swallows_update_errors(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.side_effect = RuntimeError("boom") + comment = MagicMock(id=99, body="x") + # Should not raise. + scm._mark_comment_processed(comment) + + +class TestSocketCommentClassification: + def test_get_comments_runs_check_for_socket_comments(self): + """Normalized comments must flow through Comments.check_for_socket_comments + so the overview/security/ignore keys are populated for downstream code.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + # A Socket-style "ignore" comment body. + page = { + "values": [ + { + "id": 1, + "content": {"raw": "@SocketSecurity ignore npm/foo@1.0.0"}, + "user": {"nickname": "alice", "uuid": "{u-1}"}, + } + ] + } + scm.client.request.return_value = _json_response(page) + result = scm.get_comments_for_pr() + # Comments.check_for_socket_comments populates the "ignore" bucket. + assert "ignore" in result + assert any( + "SocketSecurity ignore" in c.body for c in result["ignore"] + ) + + +class TestCommentBodyCache: + def test_has_thumbsup_uses_cache_after_fetch(self): + """get_comments_for_pr should populate the body cache so subsequent + has_thumbsup_reaction calls don't issue extra GETs per ignore comment.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + page = { + "values": [ + { + "id": 1, + "content": {"raw": f"@SocketSecurity ignore npm/foo@1.0.0\n\n{Bitbucket.PROCESSED_MARKER}"}, + "user": {"nickname": "alice", "uuid": "{u-1}"}, + }, + { + "id": 2, + "content": {"raw": "@SocketSecurity ignore npm/bar@2.0.0"}, + "user": {"nickname": "bob", "uuid": "{u-2}"}, + }, + ] + } + scm.client.request.return_value = _json_response(page) + scm.get_comments_for_pr() + calls_after_fetch = scm.client.request.call_count + + # Both should resolve from cache — no additional API calls. + assert scm.has_thumbsup_reaction(1) is True + assert scm.has_thumbsup_reaction(2) is False + assert scm.client.request.call_count == calls_after_fetch + + def test_has_thumbsup_falls_back_to_api_when_not_cached(self): + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response( + {"content": {"raw": f"body\n\n{Bitbucket.PROCESSED_MARKER}"}} + ) + # ID not seen by get_comments_for_pr — falls back to GET. + assert scm.has_thumbsup_reaction(999) is True + scm.client.request.assert_called_once() + + def test_mark_comment_processed_updates_cache(self): + """After marking, the cache reflects the new body so a subsequent + has_thumbsup_reaction in the same run sees it without re-fetching.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + comment = MagicMock(id=42, body="original") + scm._mark_comment_processed(comment) + assert scm._comment_body_cache[42].endswith(Bitbucket.PROCESSED_MARKER) + # And subsequent reaction check is a cache hit. + scm.client.request.reset_mock() + assert scm.has_thumbsup_reaction(42) is True + scm.client.request.assert_not_called() + + +class TestNullSafePaginationValues: + def test_handles_null_values_field(self): + """If Bitbucket returns {"values": null} instead of omitting the key, + the for-loop must not blow up.""" + scm = Bitbucket(client=MagicMock(), config=_make_config()) + scm.client.request.return_value = _json_response({"values": None}) + # Should not raise. + result = scm.get_comments_for_pr() + assert result == {} + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/uv.lock b/uv.lock index ec4b94f..dde4be0 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,7 +1168,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.90" +version = "2.2.92" source = { editable = "." } dependencies = [ { name = "bs4" }, diff --git a/workflows/bitbucket-pipelines.yml b/workflows/bitbucket-pipelines.yml index d129560..733dd9b 100644 --- a/workflows/bitbucket-pipelines.yml +++ b/workflows/bitbucket-pipelines.yml @@ -1,6 +1,6 @@ # Socket Security Bitbucket Pipelines # This pipeline runs Socket Security scans on every commit to any branch -# The CLI automatically detects most information from the git repository +# and posts PR comments when run against a pull request. image: socketdev/cli:latest @@ -9,22 +9,27 @@ definitions: - step: &socket-scan name: Socket Security Scan script: - # Run Socket CLI with minimal required parameters - # The CLI automatically detects: - # - Repository name from git - # - Branch name from git - # - Commit SHA from git - # - Commit message from git - # - Committer information from git - # - Default branch status from git repository - # - Changed files from git commit + # Run Socket CLI with --scm bitbucket to enable PR comments. + # The CLI automatically detects repo, branch, commit, etc. from + # BITBUCKET_* env vars exposed by Pipelines. - | socketcli \ --target-path $BITBUCKET_CLONE_DIR \ - --scm api \ + --scm bitbucket \ + --integration bitbucket \ --pr-number ${BITBUCKET_PR_ID:-0} - # Repository variables needed (set in Bitbucket repo settings) - # SOCKET_SECURITY_API_KEY: Your Socket Security API token + # Repository variables needed (set in Bitbucket repo settings): + # SOCKET_SECURITY_API_KEY: Your Socket Security API token + # BITBUCKET_TOKEN: A Repository or Workspace access token with + # "Pull requests: Write" scope + # (or set BITBUCKET_USERNAME and + # BITBUCKET_APP_PASSWORD for an app password) + # BITBUCKET_DEFAULT_BRANCH: Optional. Bitbucket Pipelines does not + # export the repo's default branch, so set + # this (e.g. "main") for accurate default- + # branch detection. You can also pass + # --default-branch on the CLI directly. + # Without either credential set, --scm bitbucket exits 2. pipelines: # Run on all branches @@ -76,6 +81,8 @@ pipelines: # '**': # - step: *socket-scan -# Note: Bitbucket Pipelines doesn't have built-in SCM integration like -# GitHub Actions or GitLab CI, so we use --scm api mode which provides -# basic scanning without PR comment functionality. +# Note: --scm bitbucket enables PR comments via the Bitbucket Cloud REST +# API. Generate a token at the workspace or repository level with at least +# the "Pull requests: Write" scope and expose it as BITBUCKET_TOKEN. For +# Bitbucket Server / Data Center, set BITBUCKET_API_URL to your server's +# REST API base URL.