Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__author__ = 'socket.dev'
__version__ = '2.2.79'
__version__ = '2.2.80'
USER_AGENT = f'SocketPythonCLI/{__version__}'
16 changes: 16 additions & 0 deletions socketsecurity/core/cli_client.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import base64
import json
import logging
from typing import Dict, List, Optional, Union

Expand Down Expand Up @@ -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}")
14 changes: 14 additions & 0 deletions socketsecurity/core/scm/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions socketsecurity/core/scm/gitlab.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import os
import sys
from dataclasses import dataclass
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 5 additions & 3 deletions socketsecurity/core/scm_comments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
116 changes: 115 additions & 1 deletion socketsecurity/socketcli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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")
Expand All @@ -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)
Expand Down
51 changes: 50 additions & 1 deletion tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,4 +122,53 @@ def test_request_with_payload(client):

args, kwargs = mock_request.call_args
assert kwargs['method'] == "POST"
assert kwargs['data'] == payload
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
Loading
Loading