Skip to content

Commit b9730b7

Browse files
leliaclaude
andcommitted
fix(fullscans): tolerate unknown purl types and skip unparseable artifacts (CE-362)
The full-scan stream can include artifacts whose purl type is not in SocketPURL_Type (e.g. "generic"), and a single such artifact failed the entire FullScanStreamResponse parse, leaving consumers with zero packages and alerts for an otherwise-successful scan. - Add the standard purl types (generic, maven, gem, nuget, cargo, ...) to SocketPURL_Type - Fall back to UNKNOWN with a warning for unrecognized purl types, the same forward-compat approach SocketCategory uses (#78) - Skip individual artifacts that fail to parse in FullScanStreamResponse.from_dict instead of discarding the response - Bump version to 3.4.0 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 82cf391 commit b9730b7

5 files changed

Lines changed: 166 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "socketdev"
7-
version = "3.3.0"
7+
version = "3.4.0"
88
requires-python = ">= 3.9"
99
dependencies = [
1010
'requests',

socketdev/fullscans/__init__.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,46 @@
1212

1313
class SocketPURL_Type(str, Enum):
1414
UNKNOWN = "unknown"
15+
APK = "apk"
16+
BITBUCKET = "bitbucket"
17+
CARGO = "cargo"
18+
COCOAPODS = "cocoapods"
19+
COMPOSER = "composer"
20+
CONAN = "conan"
21+
CONDA = "conda"
22+
CRAN = "cran"
23+
DEB = "deb"
24+
DOCKER = "docker"
25+
GEM = "gem"
26+
GENERIC = "generic"
27+
GITHUB = "github"
28+
GOLANG = "golang"
29+
HACKAGE = "hackage"
30+
HEX = "hex"
31+
HUGGINGFACE = "huggingface"
32+
LUAROCKS = "luarocks"
33+
MAVEN = "maven"
34+
MLFLOW = "mlflow"
1535
NPM = "npm"
36+
NUGET = "nuget"
37+
OCI = "oci"
38+
PUB = "pub"
1639
PYPI = "pypi"
17-
GOLANG = "golang"
40+
RPM = "rpm"
41+
SWIFT = "swift"
42+
43+
@classmethod
44+
def _missing_(cls, value):
45+
# The API can emit purl types this SDK does not know about yet. Fall
46+
# back to UNKNOWN instead of raising so one artifact cannot fail an
47+
# entire response parse (same forward-compat approach as
48+
# SocketCategory, https://github.com/SocketDev/socket-sdk-python/issues/78).
49+
log.warning(
50+
"Unknown SocketPURL_Type %r; falling back to UNKNOWN. "
51+
"Upgrade socketdev to pick up newer purl types.",
52+
value,
53+
)
54+
return cls.UNKNOWN
1855

1956

2057
class SocketIssueSeverity(str, Enum):
@@ -726,13 +763,24 @@ def to_dict(self):
726763

727764
@classmethod
728765
def from_dict(cls, data: dict) -> "FullScanStreamResponse":
766+
artifacts = None
767+
if data.get("artifacts"):
768+
artifacts = {}
769+
for artifact_id, raw in data["artifacts"].items():
770+
try:
771+
artifacts[artifact_id] = SocketArtifact.from_dict(raw)
772+
except Exception:
773+
# One malformed artifact should not fail the whole stream.
774+
log.warning(
775+
"Skipping artifact %s that could not be parsed",
776+
artifact_id,
777+
exc_info=True,
778+
)
729779
return cls(
730780
success=data["success"],
731781
status=data["status"],
732782
message=data.get("message"),
733-
artifacts={k: SocketArtifact.from_dict(v) for k, v in data["artifacts"].items()}
734-
if data.get("artifacts")
735-
else None,
783+
artifacts=artifacts,
736784
)
737785

738786

socketdev/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "3.3.0"
1+
__version__ = "3.4.0"
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
"""
2+
Unit tests for lenient SocketPURL_Type parsing (CE-362).
3+
4+
The Socket API can emit purl types the SDK does not yet know about (e.g.
5+
``"generic"``, which was missing from the enum entirely). Strict enum parsing
6+
turned one such artifact into a hard failure for the whole full-scan stream:
7+
``FullScanStreamResponse.from_dict`` raised, ``FullScans.stream`` returned
8+
``success=False`` with no artifacts, and consumers (notably socketsecurity)
9+
produced empty reports for otherwise-successful scans.
10+
11+
These tests pin two behaviors:
12+
13+
1. ``SocketPURL_Type`` resolves known purl types (including ``generic``) and
14+
falls back to ``UNKNOWN`` with a warning for unrecognized values, mirroring
15+
the ``SocketCategory`` forward-compat approach from issue #78.
16+
2. ``FullScanStreamResponse.from_dict`` skips individual artifacts that fail to
17+
parse instead of discarding the entire response.
18+
"""
19+
20+
import logging
21+
import unittest
22+
23+
from socketdev.fullscans import (
24+
FullScanStreamResponse,
25+
SocketArtifact,
26+
SocketPURL,
27+
SocketPURL_Type,
28+
)
29+
30+
31+
def _artifact_payload(artifact_id: str, purl_type: str) -> dict:
32+
return {
33+
"id": artifact_id,
34+
"type": purl_type,
35+
"name": "example-package",
36+
"version": "1.0.0",
37+
"alerts": [],
38+
}
39+
40+
41+
class TestSocketPURLTypeParsing(unittest.TestCase):
42+
"""SocketPURL_Type should tolerate unknown purl type values."""
43+
44+
def test_generic_is_recognized(self):
45+
self.assertEqual(SocketPURL_Type("generic"), SocketPURL_Type.GENERIC)
46+
47+
def test_common_ecosystems_are_recognized(self):
48+
for value in ("npm", "pypi", "golang", "maven", "gem", "nuget", "cargo"):
49+
self.assertEqual(SocketPURL_Type(value).value, value)
50+
51+
def test_unknown_type_falls_back_to_unknown(self):
52+
self.assertEqual(
53+
SocketPURL_Type("someFutureEcosystem"), SocketPURL_Type.UNKNOWN
54+
)
55+
56+
def test_unknown_type_emits_warning(self):
57+
with self.assertLogs("socketdev", level=logging.WARNING) as captured:
58+
SocketPURL_Type("someFutureEcosystem")
59+
self.assertTrue(
60+
any("Unknown SocketPURL_Type" in message for message in captured.output),
61+
f"expected a warning about the unknown purl type, got: {captured.output}",
62+
)
63+
64+
def test_socket_purl_from_dict_does_not_raise(self):
65+
purl = SocketPURL.from_dict({"type": "someFutureEcosystem", "name": "pkg"})
66+
self.assertEqual(purl.type, SocketPURL_Type.UNKNOWN)
67+
68+
def test_socket_artifact_from_dict_with_generic_type(self):
69+
artifact = SocketArtifact.from_dict(_artifact_payload("a1", "generic"))
70+
self.assertEqual(artifact.type, SocketPURL_Type.GENERIC)
71+
self.assertEqual(artifact.name, "example-package")
72+
73+
74+
class TestFullScanStreamResponseResilience(unittest.TestCase):
75+
"""One bad artifact should not empty out the whole stream response."""
76+
77+
def test_generic_artifact_is_kept(self):
78+
response = FullScanStreamResponse.from_dict(
79+
{
80+
"success": True,
81+
"status": 200,
82+
"artifacts": {
83+
"a1": _artifact_payload("a1", "npm"),
84+
"a2": _artifact_payload("a2", "generic"),
85+
},
86+
}
87+
)
88+
self.assertEqual(set(response.artifacts), {"a1", "a2"})
89+
self.assertEqual(response.artifacts["a2"].type, SocketPURL_Type.GENERIC)
90+
91+
def test_malformed_artifact_is_skipped_not_fatal(self):
92+
payload = {
93+
"success": True,
94+
"status": 200,
95+
"artifacts": {
96+
"good": _artifact_payload("good", "npm"),
97+
# Missing required "id" field, so SocketArtifact.from_dict raises.
98+
"bad": {"type": "npm", "alerts": []},
99+
},
100+
}
101+
with self.assertLogs("socketdev", level=logging.WARNING) as captured:
102+
response = FullScanStreamResponse.from_dict(payload)
103+
self.assertEqual(list(response.artifacts), ["good"])
104+
self.assertTrue(
105+
any("Skipping artifact bad" in message for message in captured.output),
106+
f"expected a warning about the skipped artifact, got: {captured.output}",
107+
)
108+
109+
110+
if __name__ == "__main__":
111+
unittest.main()

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)