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/6] 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/6] 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" }, From f6d5aa7e54943e3da2727e53022066a1f5e7df1a Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:07:00 -0400 Subject: [PATCH 3/6] fix(gitlab): harden implicit diff baseline resolution The workspace-scoped head scan lookup treated any failed request as "no baseline". The SDK logs and returns {} for every non-200, so a transient API error resolved to None, and create_new_diff answers None by creating an empty baseline scan -- reporting every dependency in the repository as newly added. An absent "results" key now raises APIFailure, and resolve_base_full_scan_id surfaces it the same way a missing --base-commit-sha baseline is surfaced. Selecting the newest scan on the default branch also reintroduced temporary scans, which the repository head pointer had excluded. The empty baseline scan that create_new_diff creates inherits the branch and commit of the run that created it, so a default-branch run whose real scan fails leaves that empty scan as the newest one. Both baseline lookups now skip tmp scans. Also unwrap scan_type before it is URL encoded. FullScanParams types it as a ScanType enum, and urlencode renders a (str, Enum) member as its repr-style name, which would filter on a scan type that does not exist. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +- socketsecurity/core/__init__.py | 85 +++++++++++++++++++++++-- tests/core/test_sdk_methods.py | 108 +++++++++++++++++++++++++++++--- 3 files changed, 183 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9987f9d..88f99d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,9 @@ - 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. + repository, and default branch. A baseline lookup that fails is reported as an + API error instead of resolving to an empty baseline, and temporary scans are + skipped when selecting one. ## 2.7.1 diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index eb21d8c7..f12ff0eb 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -51,6 +51,11 @@ _HUMANIZE_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") +# How many full scans to request when resolving a diff baseline. The newest scan is +# usually the one we want, but temporary scans have to be skipped (see +# Core.newest_persisted_scan_id), so a single result is not enough. +SCAN_LOOKUP_PAGE_SIZE = 10 + # Reachability facts-file upload compression. # # The Socket full-scan endpoint transparently brotli-decompresses any multipart part @@ -1440,6 +1445,11 @@ def get_head_scan_for_repo( """ Gets the head scan ID for a repository. + Without a workspace this is the repository's head scan pointer. That pointer + tracks a single scan for the whole repository rather than one per workspace, + so workspace-scoped runs instead take the newest matching scan on the default + branch. + Args: repo_slug: Repository slug to get head scan for workspace: Socket workspace the scan belongs to, if any @@ -1447,6 +1457,12 @@ def get_head_scan_for_repo( Returns: Head scan ID if it exists, None otherwise + + Raises: + APIFailure: If the workspace scan lookup fails. A failed lookup must not + be reported as "no baseline": the caller answers that by creating an + empty baseline scan, which reports every dependency in the repository + as newly added. """ repo_info = self.get_repo_info(repo_slug) if workspace: @@ -1456,15 +1472,58 @@ def get_head_scan_for_repo( "branch": repo_info.default_branch, "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, } if scan_type: - query_params["scan_type"] = scan_type + query_params["scan_type"] = Core.query_param_value(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 + if results is None: + # The SDK logs and returns {} for any non-200, so an empty "results" + # key is the only signal that the request itself succeeded. + raise APIFailure( + f"Failed to list full scans for repo {repo_slug} in workspace {workspace}" + ) + return Core.newest_persisted_scan_id(results) return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None + @staticmethod + def query_param_value(value): + """ + Unwraps an enum member so it survives URL encoding. + + The SDK types several params as str-backed enums (ScanType, IntegrationType). + urlencode calls str() on values, and a (str, Enum) mixin renders as + "ScanType.SOCKET_TIER1" rather than "socket_tier1", which would silently + filter on a scan type that does not exist. + """ + return getattr(value, "value", value) + + @staticmethod + def newest_persisted_scan_id(results: List[dict]) -> Optional[str]: + """ + Returns the newest scan ID from a full scan listing, skipping temporary scans. + + create_new_diff creates an empty ``tmp`` scan when a repository has no baseline + yet, and that scan inherits the branch and commit of the run that created it. + If the real scan then fails, the empty scan is left behind as the newest scan + for that branch/commit; selecting it as a baseline would report every + dependency as newly added. + + Args: + results: Full scan listing results, newest first + + Returns: + Newest non-temporary scan ID, or None if the listing has none + """ + for result in results or []: + if not isinstance(result, dict) or result.get("tmp"): + continue + scan_id = result.get("id") + if scan_id: + return scan_id + return None + def get_full_scan_id_by_commit( self, repo_slug: str, @@ -1493,12 +1552,12 @@ def get_full_scan_id_by_commit( "commit_hash": commit_sha, "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, } if workspace: query_params["workspace"] = workspace if scan_type: - query_params["scan_type"] = scan_type + query_params["scan_type"] = Core.query_param_value(scan_type) response = self.sdk.fullscans.get( self.config.org_slug, @@ -1507,7 +1566,7 @@ def get_full_scan_id_by_commit( results = response.get("results") if isinstance(response, dict) else None if not results: return None - return results[0].get("id") + return Core.newest_persisted_scan_id(results) def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: """ @@ -1556,6 +1615,20 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: ) except APIResourceNotFound: return None + except APIFailure as error: + # Only workspace-scoped lookups raise here. Returning None instead would + # make the caller create an empty baseline scan, reporting every + # dependency as newly added, so fail loudly like the --base-commit-sha + # path above. + log.error( + f"Failed to resolve the head scan for repo {params.repo} in workspace " + f"{params.workspace}: {error}" + ) + if self.cli_config is None: + raise + if self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(self.cli_config.exit_code_on_api_error) @staticmethod def update_package_values(pkg: Package) -> Package: diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index e04573dd..07b84459 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,9 +1,9 @@ import pytest from socketdev.exceptions import APIFailure -from socketdev.fullscans import FullScanParams, FullScanStreamResponse +from socketdev.fullscans import FullScanParams, FullScanStreamResponse, ScanType from socketsecurity.config import CliConfig -from socketsecurity.core import Core +from socketsecurity.core import SCAN_LOOKUP_PAGE_SIZE, Core from socketsecurity.core.socket_config import SocketConfig @@ -87,11 +87,57 @@ def test_get_head_scan_for_repo_scopes_workspace_to_default_branch( "branch": "main", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "scan_type": "socket_tier1", }, ) + +def test_get_head_scan_for_repo_workspace_lookup_failure_raises(core, mock_sdk_with_responses): + """A failed listing is not the same as an empty one and must not resolve to None""" + mock_sdk_with_responses.fullscans.get.return_value = {} + + with pytest.raises(APIFailure): + core.get_head_scan_for_repo("test", workspace="customer-a") + + +def test_get_head_scan_for_repo_workspace_no_scans_yet(core, mock_sdk_with_responses): + """An empty listing is a real answer: the workspace has no baseline yet""" + mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} + + assert core.get_head_scan_for_repo("test", workspace="customer-a") is None + + +def test_get_head_scan_for_repo_skips_temporary_scans(core, mock_sdk_with_responses): + """A leftover empty tmp scan must not be picked up as the baseline""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [ + {"id": "leftover-tmp-scan", "tmp": True}, + {"id": "workspace-head", "tmp": False}, + ], + "nextPage": None, + } + + assert core.get_head_scan_for_repo("test", workspace="customer-a") == "workspace-head" + + +def test_get_head_scan_for_repo_normalizes_enum_scan_type(core, mock_sdk_with_responses): + """ScanType members must be sent as their value, not their repr-style name""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + core.get_head_scan_for_repo( + "test", + workspace="customer-a", + scan_type=ScanType.SOCKET_TIER1, + ) + + query_params = mock_sdk_with_responses.fullscans.get.call_args.args[1] + assert query_params["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 = { @@ -109,7 +155,7 @@ def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, }, ) @@ -136,13 +182,26 @@ def test_get_full_scan_id_by_commit_scopes_to_workspace_and_scan_type(core, mock "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "workspace": "customer-a", "scan_type": "socket_tier1", }, ) +def test_get_full_scan_id_by_commit_skips_temporary_scans(core, mock_sdk_with_responses): + """A tmp scan carries the commit hash of the run that created it, so skip it too""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [ + {"id": "leftover-tmp-scan", "commit_hash": "abc123", "tmp": True}, + {"id": "base-scan-id", "commit_hash": "abc123"}, + ], + "nextPage": None, + } + + assert core.get_full_scan_id_by_commit("test", "abc123") == "base-scan-id" + + def test_get_full_scan_id_by_commit_not_found(core, mock_sdk_with_responses): """No scan for the commit returns None (empty results and SDK error dict)""" mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} @@ -173,11 +232,46 @@ def test_resolve_base_full_scan_id_scopes_head_to_workspace(core): "branch": "main", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "scan_type": "socket_tier1", }, ) +def test_resolve_base_full_scan_id_workspace_lookup_failure_exits(core): + """A failed workspace lookup fails the run instead of diffing against an empty scan""" + core.cli_config = make_cli_config() + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(params) + assert exc_info.value.code == core.cli_config.exit_code_on_api_error + + +def test_resolve_base_full_scan_id_workspace_lookup_failure_disable_blocking(core): + """--disable-blocking keeps the failed lookup from failing the build""" + core.cli_config = make_cli_config("--disable-blocking") + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(params) + assert exc_info.value.code == 0 + + +def test_resolve_base_full_scan_id_workspace_lookup_failure_without_cli_config(core): + """Library callers with no CliConfig see the APIFailure rather than a process exit""" + core.cli_config = None + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(APIFailure): + core.resolve_base_full_scan_id(params) + + 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") @@ -204,7 +298,7 @@ def test_resolve_base_full_scan_id_uses_base_commit_sha(core): "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "workspace": "customer-a", "scan_type": "socket_tier1", }, From de06e3bb97e8bcedf6a7e4ffcf953760e7d95237 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:07:09 -0400 Subject: [PATCH 4/6] fix(gitlab): match snake_case vulnerability ids in report identifiers Issue.props reaches the GitLab formatter from several sources, and core.alert_selection already matches both ghsaId/ghsa_id and cveId/cve_id when deciding reachability. The identifier extractor only read the camelCase spellings, so an alert carrying ghsa_id was selected for the report but emitted with only its socket_alert identifier -- the CVE and GHSA values GitLab dedupes and links on were dropped. Values that are neither a string nor a sequence are now skipped rather than iterated, so a malformed prop cannot raise out of the whole report. Co-Authored-By: Claude Opus 5 (1M context) --- socketsecurity/core/messages.py | 45 ++++++++++++++++++-------------- tests/unit/test_gitlab_format.py | 38 +++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 18f56fbf..d5774cce 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -657,30 +657,35 @@ def extract_identifiers_gitlab(alert: Issue) -> list: }) props = getattr(alert, "props", None) or {} + # Both spellings of each field are read because alerts reach Issue.props from + # several sources; core.alert_selection matches on the same pair. "cve" is the + # legacy property, kept for alerts produced by older API responses. identifier_fields = ( - ("cveId", "cve", "https://nvd.nist.gov/vuln/detail/"), - ("cve", "cve", "https://nvd.nist.gov/vuln/detail/"), - ("ghsaId", "ghsa", "https://github.com/advisories/"), + (("cveId", "cve_id", "cve"), "cve", "https://nvd.nist.gov/vuln/detail/"), + (("ghsaId", "ghsa_id"), "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(): + for fields, identifier_type, url_prefix in identifier_fields: + for field in fields: + values = props.get(field) + if isinstance(values, str): + values = [values] + elif not isinstance(values, (list, tuple)): 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}" - }) + for value in values: + 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/unit/test_gitlab_format.py b/tests/unit/test_gitlab_format.py index a8126c70..7b064db9 100644 --- a/tests/unit/test_gitlab_format.py +++ b/tests/unit/test_gitlab_format.py @@ -151,6 +151,44 @@ def test_identifier_extraction_deduplicates_legacy_and_current_cve_fields(self): assert [item["value"] for item in identifiers].count("CVE-2024-1111") == 1 + def test_identifier_extraction_supports_snake_case_props(self): + """Alerts can reach Issue.props with snake_case vulnerability ids""" + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Snake case ids", + props={"cve_id": "CVE-2024-2222", "ghsa_id": "GHSA-2222-3333-4444"}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + by_type = {item["type"]: item for item in identifiers} + assert by_type["cve"]["value"] == "CVE-2024-2222" + assert by_type["ghsa"]["value"] == "GHSA-2222-3333-4444" + + def test_identifier_extraction_ignores_unusable_prop_values(self): + """Malformed props must not take down the whole report""" + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Malformed props", + props={"cveId": 1234, "ghsaId": None}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + assert [item["type"] for item in identifiers] == ["socket_alert"] + def test_dependency_chain_handling_transitive(self): """Test transitive dependency path is captured""" diff = Diff() From 162aa907982417d688a65102c495ed93edf9ac9b Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:07:09 -0400 Subject: [PATCH 5/6] test: use a generic package name in the namespace normalization fixture The fixture named a real organization. Public test data should not, so use the reserved com.example namespace instead. Co-Authored-By: Claude Opus 5 (1M context) --- tests/core/test_package_and_alerts.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/core/test_package_and_alerts.py b/tests/core/test_package_and_alerts.py index 4d1fa3b1..c9e6e115 100644 --- a/tests/core/test_package_and_alerts.py +++ b/tests/core/test_package_and_alerts.py @@ -107,10 +107,10 @@ def test_create_packages_dict_basic(self, core): 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", + "id": "pkg:maven/com.example/example-core@1.2.3", "type": "maven", - "namespace": "com.arenko", - "name": "trading-core", + "namespace": "com.example", + "name": "example-core", "version": "1.2.3", "direct": True, "topLevelAncestors": [], @@ -121,9 +121,9 @@ def test_full_scan_package_normalizes_enum_type_and_namespace_url(self): package = Package.from_socket_artifact(asdict(artifact)) assert package.type == "maven" - assert package.purl == "maven/com.arenko/trading-core@1.2.3" + assert package.purl == "maven/com.example/example-core@1.2.3" assert package.url == ( - "https://socket.dev/maven/package/com.arenko/trading-core/overview/1.2.3" + "https://socket.dev/maven/package/com.example/example-core/overview/1.2.3" ) def test_create_packages_dict_with_transitives(self, core): From 1abb63649c4967bea2d9bfa3be42189ca2968cbe Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:39:23 -0400 Subject: [PATCH 6/6] fix(gitlab): use colon-separated Maven coordinates in package links Socket addresses Maven package pages as groupId:artifactId. The CLI emitted the slash-separated form, so every Maven package link 404'd -- the dashboard's Maven handler rejects the slash form outright with "Maven package must have a colon". Removing the enum leak from these URLs fixed how they looked without fixing where they pointed. The separator now follows the ecosystem, via Package.socket_url, which both the full-scan and diff construction paths call. Previously each built its URL inline and they disagreed on namespace handling, so the same package could produce different links depending on which path ran. Purl strings are deliberately left on the slash form for every ecosystem: that is what the purl spec defines and what Socket's purl API consumes. Only the dashboard URL is ecosystem-dependent. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 4 +++ socketsecurity/core/__init__.py | 7 ++-- socketsecurity/core/classes.py | 49 +++++++++++++++++++++++-- tests/core/test_package_and_alerts.py | 52 ++++++++++++++++++++++++++- 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88f99d31..de8cdff2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ - Full-scan package identities and Socket links now preserve namespaced packages when the SDK returns enum-backed ecosystem values. +- Maven package links use the `groupId:artifactId` form the Socket dashboard + expects. The slash-separated form returned a 404 for every Maven package, on + both the full-scan and diff code paths. Purl strings are unchanged and keep the + slash form the purl spec defines. - 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, diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index f12ff0eb..884f60bb 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1632,12 +1632,13 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: @staticmethod def update_package_values(pkg: Package) -> Package: + # The purl keeps the "/" form for every ecosystem; only the dashboard URL + # varies, so it is built by Package.socket_url rather than inline here. + pkg.type = Package.normalize_type(pkg.type) pkg.purl = f"{pkg.name}@{pkg.version}" - pkg.url = f"https://socket.dev/{pkg.type}/package" if pkg.namespace: pkg.purl = f"{pkg.namespace}/{pkg.purl}" - pkg.url += f"/{pkg.namespace}" - pkg.url += f"/{pkg.name}/overview/{pkg.version}" + pkg.url = Package.socket_url(pkg.type, pkg.namespace, pkg.name, pkg.version) return pkg def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: int = 5000) -> dict: diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index 46d8ffc9..dd742a80 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -11,6 +11,14 @@ SocketScore, ) +# Separator between namespace and name in a socket.dev package URL. Socket addresses +# Maven artifacts as "groupId:artifactId" -- the slash form 404s, and the dashboard's +# Maven handler raises "Maven package must have a colon" on it. Every other ecosystem +# uses a path segment per component (npm "@scope/name", golang "github.com/org/repo"). +URL_NAMESPACE_SEPARATORS = { + "maven": ":", +} + __all__ = [ "Report", "Score", @@ -142,6 +150,43 @@ class Package(): licenseAttrib: Optional[List] = None + @staticmethod + def normalize_type(package_type) -> str: + """ + Unwraps the SDK's str-backed SocketPURL_Type enum to its value. + + str(SocketPURL_Type.MAVEN) is "SocketPURL_Type.MAVEN", not "maven", so any + enum member reaching an f-string leaks the class name into user-facing output. + """ + return getattr(package_type, "value", package_type) + + @staticmethod + def socket_url(package_type, namespace: Optional[str], name: str, version: str) -> str: + """ + Builds the socket.dev package overview URL for a package. + + Maven package pages are addressed as ``groupId:artifactId``; every other + ecosystem gives the namespace its own path segment. The slash form 404s for + Maven, so the separator has to follow the ecosystem. + + Purl strings keep the "/" form for both, which is what the purl spec and + Socket's purl API expect -- only the dashboard URL differs. + + Args: + package_type: Ecosystem, as a string or SocketPURL_Type member + namespace: Package namespace (Maven groupId, npm scope), if any + name: Package name + version: Package version + + Returns: + Package overview URL on socket.dev + """ + package_type = Package.normalize_type(package_type) + namespace = (namespace or "").strip("/") + separator = URL_NAMESPACE_SEPARATORS.get(package_type, "/") + package_path = f"{namespace}{separator}{name}" if namespace else name + return f"https://socket.dev/{package_type}/package/{package_path}/overview/{version}" + @classmethod def from_socket_artifact(cls, data: dict) -> "Package": """ @@ -153,11 +198,11 @@ def from_socket_artifact(cls, data: dict) -> "Package": Returns: New Package instance """ - package_type = getattr(data["type"], "value", data["type"]) + package_type = Package.normalize_type(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']}" + url = Package.socket_url(package_type, namespace, data["name"], data["version"]) return cls( id=data["id"], name=data["name"], diff --git a/tests/core/test_package_and_alerts.py b/tests/core/test_package_and_alerts.py index c9e6e115..2c6a1424 100644 --- a/tests/core/test_package_and_alerts.py +++ b/tests/core/test_package_and_alerts.py @@ -123,7 +123,57 @@ def test_full_scan_package_normalizes_enum_type_and_namespace_url(self): assert package.type == "maven" assert package.purl == "maven/com.example/example-core@1.2.3" assert package.url == ( - "https://socket.dev/maven/package/com.example/example-core/overview/1.2.3" + "https://socket.dev/maven/package/com.example:example-core/overview/1.2.3" + ) + + def test_maven_package_url_uses_colon_between_group_and_artifact(self): + """Socket addresses Maven artifacts as groupId:artifactId; the slash form 404s""" + artifact = SocketArtifact.from_dict({ + "id": "pkg:maven/org.apache.logging.log4j/log4j-api@2.17.2", + "type": "maven", + "namespace": "org.apache.logging.log4j", + "name": "log4j-api", + "version": "2.17.2", + "direct": True, + "topLevelAncestors": [], + "manifestFiles": [{"file": "pom.xml"}], + "alerts": [], + }) + + package = Package.from_socket_artifact(asdict(artifact)) + + assert package.url == ( + "https://socket.dev/maven/package/org.apache.logging.log4j:log4j-api" + "/overview/2.17.2" + ) + # The purl keeps the "/" form, which is what the purl spec and the purl API want. + assert package.purl == "maven/org.apache.logging.log4j/log4j-api@2.17.2" + + def test_non_maven_package_url_keeps_slash_separator(self): + """npm scopes and Go module paths stay slash-delimited""" + scoped_npm = Package.socket_url("npm", "@babel", "core", "7.0.0") + assert scoped_npm == "https://socket.dev/npm/package/@babel/core/overview/7.0.0" + + unscoped = Package.socket_url("nuget", None, "newtonsoft.json", "6.0.8") + assert unscoped == "https://socket.dev/nuget/package/newtonsoft.json/overview/6.0.8" + + def test_diff_path_builds_the_same_maven_url_as_the_full_scan_path(self): + """Both package construction paths must agree, or links break on only some runs""" + package = Package( + id="pkg:maven/com.google.code.gson/gson@2.8.6", + type="maven", + name="gson", + version="2.8.6", + namespace="com.google.code.gson", + score={}, + alerts=[], + topLevelAncestors=[], + ) + + package = Core.update_package_values(package) + + assert package.url == ( + "https://socket.dev/maven/package/com.google.code.gson:gson/overview/2.8.6" ) def test_create_packages_dict_with_transitives(self, core):