Skip to content

Commit 9409baa

Browse files
authored
Merge branch 'main' into lelia/diff-scan-polling
2 parents 10579fb + d5a7f51 commit 9409baa

2 files changed

Lines changed: 205 additions & 5 deletions

File tree

socketdev/fullscans/__init__.py

Lines changed: 64 additions & 5 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

@@ -907,7 +955,18 @@ def stream(self, org_slug: str, full_scan_id: str, use_types: bool = False) -> U
907955
stream_str.append(item)
908956
stream_deduped = Dedupe.dedupe(stream_str, batched=False)
909957
for batch in stream_deduped:
910-
artifacts[batch["id"]] = batch
958+
try:
959+
artifact_id = batch["id"]
960+
if not isinstance(artifact_id, str) or not artifact_id:
961+
raise TypeError("artifact id must be a non-empty string")
962+
artifacts[artifact_id] = batch
963+
except (KeyError, TypeError):
964+
# A malformed artifact should not discard valid stream results
965+
# before FullScanStreamResponse can parse them individually.
966+
log.warning(
967+
"Skipping artifact without a usable id",
968+
exc_info=True,
969+
)
911970
if use_types:
912971
return FullScanStreamResponse.from_dict({"success": True, "status": 200, "artifacts": artifacts})
913972
return artifacts
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 json
21+
import logging
22+
import unittest
23+
24+
from socketdev.fullscans import (
25+
FullScans,
26+
FullScanStreamResponse,
27+
SocketArtifact,
28+
SocketPURL,
29+
SocketPURL_Type,
30+
)
31+
32+
33+
def _artifact_payload(artifact_id: str, purl_type: str) -> dict:
34+
return {
35+
"id": artifact_id,
36+
"type": purl_type,
37+
"name": "example-package",
38+
"version": "1.0.0",
39+
"alerts": [],
40+
}
41+
42+
43+
class TestSocketPURLTypeParsing(unittest.TestCase):
44+
"""SocketPURL_Type should tolerate unknown purl type values."""
45+
46+
def test_generic_is_recognized(self):
47+
self.assertEqual(SocketPURL_Type("generic"), SocketPURL_Type.GENERIC)
48+
49+
def test_common_ecosystems_are_recognized(self):
50+
for value in ("npm", "pypi", "golang", "maven", "gem", "nuget", "cargo"):
51+
self.assertEqual(SocketPURL_Type(value).value, value)
52+
53+
def test_unknown_type_falls_back_to_unknown(self):
54+
self.assertEqual(
55+
SocketPURL_Type("someFutureEcosystem"), SocketPURL_Type.UNKNOWN
56+
)
57+
58+
def test_unknown_type_emits_warning(self):
59+
with self.assertLogs("socketdev", level=logging.WARNING) as captured:
60+
SocketPURL_Type("someFutureEcosystem")
61+
self.assertTrue(
62+
any("Unknown SocketPURL_Type" in message for message in captured.output),
63+
f"expected a warning about the unknown purl type, got: {captured.output}",
64+
)
65+
66+
def test_socket_purl_from_dict_does_not_raise(self):
67+
purl = SocketPURL.from_dict({"type": "someFutureEcosystem", "name": "pkg"})
68+
self.assertEqual(purl.type, SocketPURL_Type.UNKNOWN)
69+
70+
def test_socket_artifact_from_dict_with_generic_type(self):
71+
artifact = SocketArtifact.from_dict(_artifact_payload("a1", "generic"))
72+
self.assertEqual(artifact.type, SocketPURL_Type.GENERIC)
73+
self.assertEqual(artifact.name, "example-package")
74+
75+
76+
class TestFullScanStreamResponseResilience(unittest.TestCase):
77+
"""One bad artifact should not empty out the whole stream response."""
78+
79+
def test_generic_artifact_is_kept(self):
80+
response = FullScanStreamResponse.from_dict(
81+
{
82+
"success": True,
83+
"status": 200,
84+
"artifacts": {
85+
"a1": _artifact_payload("a1", "npm"),
86+
"a2": _artifact_payload("a2", "generic"),
87+
},
88+
}
89+
)
90+
self.assertEqual(set(response.artifacts), {"a1", "a2"})
91+
self.assertEqual(response.artifacts["a2"].type, SocketPURL_Type.GENERIC)
92+
93+
def test_malformed_artifact_is_skipped_not_fatal(self):
94+
payload = {
95+
"success": True,
96+
"status": 200,
97+
"artifacts": {
98+
"good": _artifact_payload("good", "npm"),
99+
# Missing required "id" field, so SocketArtifact.from_dict raises.
100+
"bad": {"type": "npm", "alerts": []},
101+
},
102+
}
103+
with self.assertLogs("socketdev", level=logging.WARNING) as captured:
104+
response = FullScanStreamResponse.from_dict(payload)
105+
self.assertEqual(list(response.artifacts), ["good"])
106+
self.assertTrue(
107+
any("Skipping artifact bad" in message for message in captured.output),
108+
f"expected a warning about the skipped artifact, got: {captured.output}",
109+
)
110+
111+
def test_full_scans_stream_skips_artifact_without_id(self):
112+
class Response:
113+
status_code = 200
114+
text = "\n".join(
115+
json.dumps(artifact)
116+
for artifact in (
117+
_artifact_payload("good", "npm"),
118+
{"type": "npm", "name": "bad", "alerts": []},
119+
)
120+
)
121+
122+
class API:
123+
def do_request(self, **kwargs):
124+
return Response()
125+
126+
with self.assertLogs("socketdev", level=logging.WARNING) as captured:
127+
response = FullScans(API()).stream("org", "scan", use_types=True)
128+
129+
self.assertTrue(response.success)
130+
self.assertEqual(list(response.artifacts), ["good"])
131+
self.assertTrue(
132+
any(
133+
"Skipping artifact without a usable id" in message
134+
for message in captured.output
135+
),
136+
f"expected a warning about the skipped artifact, got: {captured.output}",
137+
)
138+
139+
140+
if __name__ == "__main__":
141+
unittest.main()

0 commit comments

Comments
 (0)