diff --git a/pyproject.toml b/pyproject.toml index 7b7cd2a..6b37502 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.79" +version = "2.2.80" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index e56db94..92eb029 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.79' +__version__ = '2.2.80' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index 8bb2ed6..bfad0d1 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -1,4 +1,5 @@ import base64 +import json import logging from typing import Dict, List, Optional, Union @@ -55,3 +56,18 @@ def request( except requests.exceptions.RequestException as e: logger.error(f"API request failed: {str(e)}") raise APIFailure(f"Request failed: {str(e)}") + + def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None: + """Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget — logs errors but never raises.""" + logger.debug(f"Sending {len(events)} telemetry event(s) to v0/orgs/{org_slug}/telemetry") + for i, event in enumerate(events): + try: + logger.debug(f"Telemetry event {i+1}/{len(events)}: {json.dumps(event)}") + resp = self.request( + path=f"orgs/{org_slug}/telemetry", + method="POST", + payload=json.dumps(event), + ) + logger.debug(f"Telemetry event {i+1}/{len(events)} sent: status={resp.status_code}") + except Exception as e: + logger.warning(f"Failed to send telemetry event {i+1}/{len(events)}: {e}") diff --git a/socketsecurity/core/scm/github.py b/socketsecurity/core/scm/github.py index 7d5905d..bba0dde 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -211,6 +211,20 @@ def post_reaction(self, comment_id: int) -> None: base_url=self.config.api_url ) + def post_eyes_reaction(self, comment_id: int) -> None: + path = f"repos/{self.config.owner}/{self.config.repository}/issues/comments/{comment_id}/reactions" + payload = json.dumps({"content": "eyes"}) + try: + self.client.request( + path=path, + payload=payload, + method="POST", + headers=self.config.headers, + base_url=self.config.api_url + ) + except Exception as error: + log.warning(f"Failed to add eyes reaction to comment {comment_id}: {error}") + def comment_reaction_exists(self, comment_id: int) -> bool: path = f"repos/{self.config.owner}/{self.config.repository}/issues/comments/{comment_id}/reactions" try: diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index e88e050..c4f001f 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -1,3 +1,4 @@ +import json import os import sys from dataclasses import dataclass @@ -219,6 +220,24 @@ def update_comment(self, body: str, comment_id: str) -> None: base_url=self.config.api_url ) + def has_eyes_reaction(self, comment_id: int) -> bool: + """Best-effort check for 'eyes' award emoji on a MR note.""" + if not self.config.mr_project_id or not self.config.mr_iid: + return False + path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji" + try: + response = self._request_with_fallback( + path=path, + headers=self.config.headers, + base_url=self.config.api_url + ) + for emoji in response.json(): + if emoji.get("name") == "eyes": + return True + except Exception as e: + log.debug(f"Could not check award emoji for note {comment_id} (best effort): {e}") + return False + def get_comments_for_pr(self) -> dict: log.debug(f"Getting Gitlab comments for Repo {self.config.repository} for PR {self.config.mr_iid}") path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes" @@ -326,6 +345,23 @@ def set_commit_status(self, state: str, description: str, target_url: str = '') except Exception as e: log.error(f"Failed to set commit status: {e}") + def post_eyes_reaction(self, comment_id: int) -> None: + """Best-effort: add 'eyes' award emoji to a MR note. The token may lack permission.""" + if not self.config.mr_project_id or not self.config.mr_iid: + return + path = f"projects/{self.config.mr_project_id}/merge_requests/{self.config.mr_iid}/notes/{comment_id}/award_emoji" + try: + headers = {**self.config.headers, "Content-Type": "application/json"} + self._request_with_fallback( + path=path, + payload=json.dumps({"name": "eyes"}), + method="POST", + headers=headers, + base_url=self.config.api_url + ) + except Exception as e: + log.debug(f"Could not add eyes emoji to note {comment_id} (best effort): {e}") + def remove_comment_alerts(self, comments: dict): security_alert = comments.get("security") if security_alert is not None: diff --git a/socketsecurity/core/scm_comments.py b/socketsecurity/core/scm_comments.py index bd1b4d7..741578e 100644 --- a/socketsecurity/core/scm_comments.py +++ b/socketsecurity/core/scm_comments.py @@ -51,11 +51,13 @@ def get_ignore_options(comments: dict) -> [bool, list]: for comment in comments["ignore"]: comment: Comment first_line = comment.body_list[0] - if not ignore_all and "SocketSecurity ignore" in first_line: + if not ignore_all and "socketsecurity ignore" in first_line.lower(): try: first_line = first_line.lstrip("@") - _, command = first_line.split("SocketSecurity ") - command = command.strip() + # Case-insensitive split: find "SocketSecurity " regardless of casing + lower_line = first_line.lower() + split_idx = lower_line.index("socketsecurity ") + len("socketsecurity ") + command = first_line[split_idx:].strip() if command == "ignore-all": ignore_all = True else: diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 6f17f58..e349425 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -4,6 +4,8 @@ import traceback import shutil import warnings +from datetime import datetime, timezone +from uuid import uuid4 from dotenv import load_dotenv from git import InvalidGitRepositoryError, NoSuchPathError @@ -478,6 +480,17 @@ def main_code(): # Handle SCM-specific flows log.debug(f"Flow decision: scm={scm is not None}, force_diff_mode={force_diff_mode}, force_api_mode={force_api_mode}, enable_diff={config.enable_diff}") + + def _is_unprocessed(c): + """Check if an ignore comment has not yet been marked with 'eyes' reaction. + For GitHub, reactions.eyes is already in the comment response (no extra call). + For GitLab, has_eyes_reaction() makes a lazy API call per comment.""" + if getattr(c, "reactions", {}).get("eyes"): + return False + if hasattr(scm, "has_eyes_reaction") and scm.has_eyes_reaction(c.id): + return False + return True + if scm is not None and scm.check_event_type() == "comment": # FIXME: This entire flow should be a separate command called "filter_ignored_alerts_in_comments" # It's not related to scanning or diff generation - it just: @@ -486,10 +499,52 @@ def main_code(): # 3. Updates the comment to remove ignored alerts # This is completely separate from the main scanning functionality log.info("Comment initiated flow") - + comments = scm.get_comments_for_pr() + log.debug("Removing comment alerts") scm.remove_comment_alerts(comments) + + # Emit telemetry only for ignore comments not yet marked with 'eyes' reaction. + # Process each comment individually so the comment author is recorded per event. + if "ignore" in comments: + unprocessed = [c for c in comments["ignore"] if _is_unprocessed(c)] + if unprocessed: + try: + events = [] + for c in unprocessed: + single = {"ignore": [c]} + ignore_all, ignore_commands = Comments.get_ignore_options(single) + user = getattr(c, "user", None) or getattr(c, "author", None) or {} + now = datetime.now(timezone.utc).isoformat() + shared_fields = { + "event_kind": "user-action", + "client_action": "ignore_alerts", + "event_sender_created_at": now, + "vcs_provider": integration_type, + "owner": config.repo.split("/")[0] if "/" in config.repo else "", + "repo": config.repo, + "pr_number": pr_number, + "ignore_all": ignore_all, + "sender_name": user.get("login") or user.get("username", ""), + "sender_id": str(user.get("id", "")), + } + if ignore_commands: + for name, version in ignore_commands: + events.append({**shared_fields, "event_id": str(uuid4()), "artifact_input": f"{name}@{version}"}) + elif ignore_all: + events.append({**shared_fields, "event_id": str(uuid4())}) + + if events: + log.debug(f"Ignore telemetry: {len(events)} events to send") + client.post_telemetry_events(org_slug, events) + + # Mark as processed with eyes reaction + if hasattr(scm, "post_eyes_reaction"): + for c in unprocessed: + scm.post_eyes_reaction(c.id) + except Exception as e: + log.warning(f"Failed to send ignore telemetry: {e}") elif scm is not None and scm.check_event_type() != "comment" and not force_api_mode: log.info("Push initiated flow") @@ -500,7 +555,66 @@ def main_code(): log.debug("Removing comment alerts") # FIXME: this overwrites diff.new_alerts, which was previously populated by Core.create_issue_alerts + alerts_before = list(diff.new_alerts) diff.new_alerts = Comments.remove_alerts(comments, diff.new_alerts) + + ignored_alerts = [a for a in alerts_before if a not in diff.new_alerts] + # Emit telemetry per-comment so each event carries the comment author. + unprocessed_ignore = [ + c for c in comments.get("ignore", []) + if _is_unprocessed(c) + ] + if ignored_alerts and unprocessed_ignore: + try: + events = [] + now = datetime.now(timezone.utc).isoformat() + for c in unprocessed_ignore: + single = {"ignore": [c]} + c_ignore_all, c_ignore_commands = Comments.get_ignore_options(single) + user = getattr(c, "user", None) or getattr(c, "author", None) or {} + sender_name = user.get("login") or user.get("username", "") + sender_id = str(user.get("id", "")) + + # Match this comment's targets to the actual ignored alerts + matched_alerts = [] + if c_ignore_all: + matched_alerts = ignored_alerts + else: + for alert in ignored_alerts: + full_name = f"{alert.pkg_type}/{alert.pkg_name}" + purl = (full_name, alert.pkg_version) + purl_star = (full_name, "*") + if purl in c_ignore_commands or purl_star in c_ignore_commands: + matched_alerts.append(alert) + + shared_fields = { + "event_kind": "user-action", + "client_action": "ignore_alerts", + "event_sender_created_at": now, + "vcs_provider": integration_type, + "owner": config.repo.split("/")[0] if "/" in config.repo else "", + "repo": config.repo, + "pr_number": pr_number, + "ignore_all": c_ignore_all, + "sender_name": sender_name, + "sender_id": sender_id, + } + if matched_alerts: + for alert in matched_alerts: + events.append({**shared_fields, "event_id": str(uuid4()), "artifact_purl": alert.purl}) + elif c_ignore_all: + events.append({**shared_fields, "event_id": str(uuid4())}) + + if events: + client.post_telemetry_events(org_slug, events) + + # Mark ignore comments as processed + if hasattr(scm, "post_eyes_reaction"): + for c in unprocessed_ignore: + scm.post_eyes_reaction(c.id) + except Exception as e: + log.warning(f"Failed to send ignore telemetry: {e}") + log.debug("Creating Dependency Overview Comment") overview_comment = Messages.dependency_overview_template(diff) diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 3998d39..e0f62d8 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -122,4 +122,53 @@ def test_request_with_payload(client): args, kwargs = mock_request.call_args assert kwargs['method'] == "POST" - assert kwargs['data'] == payload \ No newline at end of file + assert kwargs['data'] == payload + + +def test_post_telemetry_events_sends_individually(client): + """Test that telemetry events are posted one at a time to v0 API""" + import json + + events = [ + {"event_kind": "user-action", "client_action": "ignore_alerts", "artifact_purl": "pkg:npm/foo@1.0.0"}, + {"event_kind": "user-action", "client_action": "ignore_alerts", "artifact_purl": "pkg:npm/bar@2.0.0"}, + ] + + with patch('requests.request') as mock_request: + mock_response = Mock() + mock_response.status_code = 201 + mock_request.return_value = mock_response + + client.post_telemetry_events("test-org", events) + + assert mock_request.call_count == 2 + + first_call = mock_request.call_args_list[0] + assert first_call.kwargs['url'] == "https://api.socket.dev/v0/orgs/test-org/telemetry" + assert first_call.kwargs['method'] == "POST" + assert first_call.kwargs['data'] == json.dumps(events[0]) + + second_call = mock_request.call_args_list[1] + assert second_call.kwargs['data'] == json.dumps(events[1]) + + +def test_post_telemetry_events_continues_on_failure(client): + """Test that a failed event does not prevent subsequent events from being sent""" + import json + + events = [ + {"event_kind": "user-action", "artifact_purl": "pkg:npm/foo@1.0.0"}, + {"event_kind": "user-action", "artifact_purl": "pkg:npm/bar@2.0.0"}, + ] + + with patch('requests.request') as mock_request: + mock_response = Mock() + mock_response.status_code = 201 + mock_request.side_effect = [ + requests.exceptions.ConnectionError("timeout"), + mock_response, + ] + + client.post_telemetry_events("test-org", events) + + assert mock_request.call_count == 2 diff --git a/tests/unit/test_ignore_telemetry_filtering.py b/tests/unit/test_ignore_telemetry_filtering.py new file mode 100644 index 0000000..3e5d14c --- /dev/null +++ b/tests/unit/test_ignore_telemetry_filtering.py @@ -0,0 +1,161 @@ +"""Tests for the eyes-reaction dedup logic used to filter ignore comments for telemetry.""" + +from unittest.mock import Mock + +from socketsecurity.core.classes import Comment +from socketsecurity.core.scm_comments import Comments + + +def _make_comment(body: str, eyes: int = 0, comment_id: int = 1, user: dict | None = None) -> Comment: + return Comment( + id=comment_id, + body=body, + body_list=body.split("\n"), + reactions={"eyes": eyes, "+1": 1}, + user=user or {"login": "test-user", "id": 123}, + ) + + +def _filter_unprocessed(comments: list[Comment], scm=None) -> list[Comment]: + """Mirrors the _is_unprocessed logic in socketcli.py.""" + def _is_unprocessed(c): + if getattr(c, "reactions", {}).get("eyes"): + return False + if hasattr(scm, "has_eyes_reaction") and scm.has_eyes_reaction(c.id): + return False + return True + + return [c for c in comments if _is_unprocessed(c)] + + +class TestUnprocessedIgnoreFiltering: + def test_returns_comments_without_eyes(self): + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=0, comment_id=1), + _make_comment("SocketSecurity ignore npm/express@4.18.2", eyes=0, comment_id=2), + ] + result = _filter_unprocessed(comments) + assert len(result) == 2 + + def test_excludes_comments_with_eyes(self): + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=1, comment_id=1), + _make_comment("SocketSecurity ignore npm/express@4.18.2", eyes=0, comment_id=2), + ] + result = _filter_unprocessed(comments) + assert len(result) == 1 + assert result[0].id == 2 + + def test_returns_empty_when_all_processed(self): + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=1, comment_id=1), + _make_comment("SocketSecurity ignore-all", eyes=2, comment_id=2), + ] + result = _filter_unprocessed(comments) + assert len(result) == 0 + + def test_handles_missing_reactions_attr(self): + c = Comment(id=1, body="SocketSecurity ignore npm/foo@1.0.0", body_list=["SocketSecurity ignore npm/foo@1.0.0"]) + # No reactions attribute set at all + result = _filter_unprocessed([c]) + assert len(result) == 1 + + def test_handles_empty_reactions_dict(self): + c = _make_comment("SocketSecurity ignore npm/foo@1.0.0", comment_id=1) + c.reactions = {} + result = _filter_unprocessed([c]) + assert len(result) == 1 + + def test_handles_reactions_with_eyes_zero(self): + c = _make_comment("SocketSecurity ignore npm/foo@1.0.0", eyes=0, comment_id=1) + result = _filter_unprocessed([c]) + assert len(result) == 1 + + +class TestUnprocessedIgnoreFilteringWithScmFallback: + """Tests for the has_eyes_reaction fallback path (GitLab).""" + + def test_scm_fallback_excludes_processed_comments(self): + """When inline reactions.eyes is 0 but scm says it has eyes, exclude it.""" + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=0, comment_id=1), + _make_comment("SocketSecurity ignore npm/express@4.18.2", eyes=0, comment_id=2), + ] + scm = Mock() + scm.has_eyes_reaction = Mock(side_effect=lambda cid: cid == 1) + + result = _filter_unprocessed(comments, scm=scm) + assert len(result) == 1 + assert result[0].id == 2 + + def test_scm_fallback_not_called_when_inline_eyes_present(self): + """When inline reactions.eyes is truthy, scm.has_eyes_reaction should not be called.""" + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=1, comment_id=1), + ] + scm = Mock() + scm.has_eyes_reaction = Mock(return_value=False) + + result = _filter_unprocessed(comments, scm=scm) + assert len(result) == 0 + scm.has_eyes_reaction.assert_not_called() + + def test_scm_without_has_eyes_reaction_skips_fallback(self): + """When scm doesn't have has_eyes_reaction (e.g. GitHub), only inline check runs.""" + comments = [ + _make_comment("SocketSecurity ignore npm/foo@1.0.0", eyes=0, comment_id=1), + ] + scm = Mock(spec=[]) # no methods at all + + result = _filter_unprocessed(comments, scm=scm) + assert len(result) == 1 + + def test_scm_fallback_returns_all_unprocessed(self): + """When scm says none have eyes, all are returned.""" + comments = [ + _make_comment("SocketSecurity ignore npm/foo@1.0.0", eyes=0, comment_id=1), + _make_comment("SocketSecurity ignore npm/bar@2.0.0", eyes=0, comment_id=2), + ] + scm = Mock() + scm.has_eyes_reaction = Mock(return_value=False) + + result = _filter_unprocessed(comments, scm=scm) + assert len(result) == 2 + + +class TestUnprocessedIgnoreFilteringWithCommentsParsing: + """Integration: filter unprocessed comments, then parse ignore options.""" + + def test_only_new_artifacts_are_parsed(self): + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=1, comment_id=1), + _make_comment("SocketSecurity ignore npm/express@4.18.2", eyes=0, comment_id=2), + _make_comment("SocketSecurity ignore npm/axios@1.6.0", eyes=0, comment_id=3), + ] + unprocessed = _filter_unprocessed(comments) + unprocessed_comments = {"ignore": unprocessed} + ignore_all, ignore_commands = Comments.get_ignore_options(unprocessed_comments) + + assert not ignore_all + assert ("npm/express", "4.18.2") in ignore_commands + assert ("npm/axios", "1.6.0") in ignore_commands + assert ("npm/lodash", "4.17.21") not in ignore_commands + + def test_ignore_all_from_unprocessed(self): + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=1, comment_id=1), + _make_comment("SocketSecurity ignore-all", eyes=0, comment_id=2), + ] + unprocessed = _filter_unprocessed(comments) + unprocessed_comments = {"ignore": unprocessed} + ignore_all, ignore_commands = Comments.get_ignore_options(unprocessed_comments) + + assert ignore_all + + def test_no_unprocessed_means_no_telemetry(self): + comments = [ + _make_comment("SocketSecurity ignore npm/lodash@4.17.21", eyes=1, comment_id=1), + _make_comment("SocketSecurity ignore npm/express@4.18.2", eyes=2, comment_id=2), + ] + unprocessed = _filter_unprocessed(comments) + assert len(unprocessed) == 0 diff --git a/uv.lock b/uv.lock index 4c6607f..5b3776d 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,7 +1168,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.78" +version = "2.2.80" source = { editable = "." } dependencies = [ { name = "bs4" },