From 691ca08a0e6d05556e07fc94f923dc577c634a81 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:30:49 -0400 Subject: [PATCH 1/2] fix(gitlab): stabilize report data --- socketsecurity/core/__init__.py | 29 +++++++++++++-- socketsecurity/core/classes.py | 16 ++++----- socketsecurity/core/messages.py | 45 +++++++++++++---------- tests/core/test_package_and_alerts.py | 25 +++++++++++-- tests/core/test_sdk_methods.py | 52 +++++++++++++++++++++++++++ tests/unit/test_gitlab_format.py | 33 +++++++++++++---- 6 files changed, 162 insertions(+), 38 deletions(-) diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index a5305bee..eb21d8c7 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1431,17 +1431,38 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br return response.data - def get_head_scan_for_repo(self, repo_slug: str) -> str: + def get_head_scan_for_repo( + self, + repo_slug: str, + workspace: Optional[str] = None, + scan_type: Optional[str] = None, + ) -> Optional[str]: """ Gets the head scan ID for a repository. Args: repo_slug: Repository slug to get head scan for + workspace: Socket workspace the scan belongs to, if any + scan_type: Socket scan type to match, if any Returns: Head scan ID if it exists, None otherwise """ repo_info = self.get_repo_info(repo_slug) + if workspace: + query_params = { + "repo": repo_slug, + "workspace": workspace, + "branch": repo_info.default_branch, + "sort": "created_at", + "direction": "desc", + "per_page": 1, + } + if scan_type: + query_params["scan_type"] = scan_type + response = self.sdk.fullscans.get(self.config.org_slug, query_params) + results = response.get("results") if isinstance(response, dict) else None + return results[0].get("id") if results else None return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None def get_full_scan_id_by_commit( @@ -1528,7 +1549,11 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: return scan_id try: - return self.get_head_scan_for_repo(params.repo) + return self.get_head_scan_for_repo( + params.repo, + workspace=params.workspace, + scan_type=params.scan_type, + ) except APIResourceNotFound: return None diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index db145221..46d8ffc9 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -153,18 +153,16 @@ def from_socket_artifact(cls, data: dict) -> "Package": Returns: New Package instance """ - purl = f"{data['type']}/" - namespace = data.get("namespace") - if namespace: - purl += f"{namespace}@" - purl += f"{data['name']}@{data['version']}" - base_url = "https://socket.dev" - url = f"{base_url}/{data['type']}/package/{namespace or ''}{data['name']}/overview/{data['version']}" + package_type = getattr(data["type"], "value", data["type"]) + namespace = (data.get("namespace") or "").strip("/") + package_path = "/".join(part for part in (namespace, data["name"]) if part) + purl = f"{package_type}/{package_path}@{data['version']}" + url = f"https://socket.dev/{package_type}/package/{package_path}/overview/{data['version']}" return cls( id=data["id"], name=data["name"], version=data["version"], - type=data["type"], + type=package_type, release=data.get("release"), diffType=data.get("diffType"), score=data["score"], @@ -179,7 +177,7 @@ def from_socket_artifact(cls, data: dict) -> "Package": artifact=data.get("artifact"), purl=purl, url=url, - namespace=namespace + namespace=namespace or None ) @classmethod diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 673dde5c..18f56fbf 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -5,6 +5,7 @@ import uuid from datetime import datetime, timezone from pathlib import Path + from mdutils import MdUtils from prettytable import PrettyTable @@ -655,25 +656,31 @@ def extract_identifiers_gitlab(alert: Issue) -> list: "url": alert.url if hasattr(alert, 'url') and alert.url else None }) - # Extract CVE identifiers from props - if hasattr(alert, 'props') and alert.props: - if 'cve' in alert.props: - cves = alert.props['cve'] - if isinstance(cves, list): - for cve in cves: - identifiers.append({ - "type": "cve", - "name": cve, - "value": cve, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve}" - }) - elif isinstance(cves, str): - identifiers.append({ - "type": "cve", - "name": cves, - "value": cves, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cves}" - }) + props = getattr(alert, "props", None) or {} + identifier_fields = ( + ("cveId", "cve", "https://nvd.nist.gov/vuln/detail/"), + ("cve", "cve", "https://nvd.nist.gov/vuln/detail/"), + ("ghsaId", "ghsa", "https://github.com/advisories/"), + ) + seen = set() + for field, identifier_type, url_prefix in identifier_fields: + values = props.get(field, []) + if isinstance(values, str): + values = [values] + for value in values or []: + if not isinstance(value, str) or not value.strip(): + continue + value = value.strip() + identifier_key = (identifier_type, value.upper()) + if identifier_key in seen: + continue + seen.add(identifier_key) + identifiers.append({ + "type": identifier_type, + "name": value, + "value": value, + "url": f"{url_prefix}{value}" + }) return identifiers diff --git a/tests/core/test_package_and_alerts.py b/tests/core/test_package_and_alerts.py index 171eae77..4d1fa3b1 100644 --- a/tests/core/test_package_and_alerts.py +++ b/tests/core/test_package_and_alerts.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from unittest.mock import Mock import pytest from socketdev import socketdev +from socketdev.fullscans import SocketArtifact from socketsecurity.core import Core, _humanize_alert_type from socketsecurity.core.classes import Issue, Package @@ -104,6 +105,27 @@ def test_create_packages_dict_basic(self, core): assert pkg.version == "1.0.0" assert pkg.transitives == 0 + def test_full_scan_package_normalizes_enum_type_and_namespace_url(self): + artifact = SocketArtifact.from_dict({ + "id": "pkg:maven/com.arenko/trading-core@1.2.3", + "type": "maven", + "namespace": "com.arenko", + "name": "trading-core", + "version": "1.2.3", + "direct": True, + "topLevelAncestors": [], + "manifestFiles": [{"file": "pom.xml"}], + "alerts": [], + }) + + package = Package.from_socket_artifact(asdict(artifact)) + + assert package.type == "maven" + assert package.purl == "maven/com.arenko/trading-core@1.2.3" + assert package.url == ( + "https://socket.dev/maven/package/com.arenko/trading-core/overview/1.2.3" + ) + def test_create_packages_dict_with_transitives(self, core): """Test package dictionary creation with transitive dependencies""" mock_artifacts = [ @@ -340,4 +362,3 @@ def test_empty_input_returns_empty_string(self): def test_handles_acronyms_conservatively(self): """Adjacent capitals are kept together: SQLInjection -> 'SQL Injection'.""" assert _humanize_alert_type("SQLInjection") == "SQL Injection" - diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index da0efc62..e04573dd 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -63,6 +63,35 @@ def test_get_head_scan_for_repo_no_head(core, mock_sdk_with_responses): head_scan_id = core.get_head_scan_for_repo("no-head") assert head_scan_id is None + +def test_get_head_scan_for_repo_scopes_workspace_to_default_branch( + core, mock_sdk_with_responses +): + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + head_scan_id = core.get_head_scan_for_repo( + "test", + workspace="customer-a", + scan_type="socket_tier1", + ) + + assert head_scan_id == "workspace-head" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "workspace": "customer-a", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": 1, + "scan_type": "socket_tier1", + }, + ) + def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): """Looks up the newest full scan for a repo + commit via the list endpoint""" mock_sdk_with_responses.fullscans.get.return_value = { @@ -126,6 +155,29 @@ def test_resolve_base_full_scan_id_defaults_to_head_scan(core): """Without base overrides the repository head scan is the baseline""" assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + +def test_resolve_base_full_scan_id_scopes_head_to_workspace(core): + core.sdk.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + params = make_full_scan_params(workspace="customer-a", scan_type="socket_tier1") + + assert core.resolve_base_full_scan_id(params) == "workspace-head" + core.sdk.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "workspace": "customer-a", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": 1, + "scan_type": "socket_tier1", + }, + ) + def test_resolve_base_full_scan_id_uses_base_scan_id(core): """--base-scan-id is used verbatim, without touching the repo endpoint""" core.cli_config = make_cli_config("--base-scan-id", "explicit-base") diff --git a/tests/unit/test_gitlab_format.py b/tests/unit/test_gitlab_format.py index 96218e4e..a8126c70 100644 --- a/tests/unit/test_gitlab_format.py +++ b/tests/unit/test_gitlab_format.py @@ -1,8 +1,7 @@ import re -import pytest -from socketsecurity.core.messages import Messages from socketsecurity.core.classes import Diff, Issue +from socketsecurity.core.messages import Messages class TestGitLabFormat: @@ -87,7 +86,10 @@ def test_identifier_extraction_with_cve(self): type="vulnerability", severity="critical", title="Known CVE", - props={"cve": ["CVE-2024-5678", "CVE-2024-9012"]}, + props={ + "cveId": ["CVE-2024-5678", "CVE-2024-9012"], + "ghsaId": "GHSA-1234-5678-9012", + }, pkg_type="npm", key="test-key", purl="pkg:npm/vulnerable-pkg@2.0.0" @@ -97,15 +99,17 @@ def test_identifier_extraction_with_cve(self): report = Messages.create_security_comment_gitlab(diff) vuln = report["vulnerabilities"][0] - # Should have socket_alert identifier + 2 CVE identifiers - assert len(vuln["identifiers"]) >= 3 + # Should have socket_alert identifier + CVE and GHSA identifiers + assert len(vuln["identifiers"]) == 4 cve_identifiers = [i for i in vuln["identifiers"] if i["type"] == "cve"] assert len(cve_identifiers) == 2 assert any(i["value"] == "CVE-2024-5678" for i in cve_identifiers) assert any(i["value"] == "CVE-2024-9012" for i in cve_identifiers) + ghsa_identifiers = [i for i in vuln["identifiers"] if i["type"] == "ghsa"] + assert ghsa_identifiers[0]["value"] == "GHSA-1234-5678-9012" def test_identifier_extraction_with_single_cve_string(self): - """Test single CVE identifier as string""" + """Legacy CVE property remains supported""" diff = Diff() diff.id = "test-scan-id" diff.diff_url = "https://socket.dev/test" @@ -130,6 +134,23 @@ def test_identifier_extraction_with_single_cve_string(self): assert len(cve_identifiers) == 1 assert cve_identifiers[0]["value"] == "CVE-2024-1111" + def test_identifier_extraction_deduplicates_legacy_and_current_cve_fields(self): + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Duplicate CVE", + props={"cve": "CVE-2024-1111", "cveId": "CVE-2024-1111"}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + assert [item["value"] for item in identifiers].count("CVE-2024-1111") == 1 + def test_dependency_chain_handling_transitive(self): """Test transitive dependency path is captured""" diff = Diff() From 2bb42d4ac08c1d62733f40d21bfa54976bfc93e5 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Thu, 3 Sep 2026 15:33:43 -0400 Subject: [PATCH 2/2] chore: bump version to 2.8.1 --- CHANGELOG.md | 11 +++++++++++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1007a3b9..e9987f9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 2.8.1 + +### Fixed: GitLab report serialization and workspace baselines + +- Full-scan package identities and Socket links now preserve namespaced packages + when the SDK returns enum-backed ecosystem values. +- GitLab dependency-scanning reports emit CVE and GHSA identifiers from current + API fields while remaining compatible with legacy CVE data. +- Implicit diff baselines are selected from the same workspace, scan type, + repository, and default branch. + ## 2.7.1 ### Changed: bump pinned @coana-tech/cli to 15.10.36 diff --git a/pyproject.toml b/pyproject.toml index 3dcd5718..ec8f544d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.1" +version = "2.8.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 78220a1f..6cf31cd7 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.1' +__version__ = '2.8.1' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/uv.lock b/uv.lock index d0338009..d722ee6c 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.1" +version = "2.8.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },