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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
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.7.1"
version = "2.8.1"
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.7.1'
__version__ = '2.8.1'
USER_AGENT = f'SocketPythonCLI/{__version__}'
29 changes: 27 additions & 2 deletions socketsecurity/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand Down
16 changes: 7 additions & 9 deletions socketsecurity/core/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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
Expand Down
45 changes: 26 additions & 19 deletions socketsecurity/core/messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import uuid
from datetime import datetime, timezone
from pathlib import Path

from mdutils import MdUtils
from prettytable import PrettyTable

Expand Down Expand Up @@ -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

Expand Down
25 changes: 23 additions & 2 deletions tests/core/test_package_and_alerts.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 = [
Expand Down Expand Up @@ -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"

52 changes: 52 additions & 0 deletions tests/core/test_sdk_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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")
Expand Down
33 changes: 27 additions & 6 deletions tests/unit/test_gitlab_format.py
Original file line number Diff line number Diff line change
@@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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"
Expand All @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.