Skip to content

Commit ab28183

Browse files
leliaclaude
andcommitted
fix(purl): expose fail-open batch params and harden dedupe (CE-360)
purl.post() defaulted to the batch API's fail-open behavior with no way to opt out: unresolved input purls are silently omitted from the response, so callers could not tell "clean" from "dropped". Add typed poll/timeout_sec/ alerts/purl_errors params (None => omit, preserving the fail-open default for existing callers) plus a strict=True guard that raises APIPartialResponse when requested purls are missing from the response. Also harden Dedupe.consolidate_and_merge_alerts to use .get() for key/type/severity/action so synthetic pendingScan/notFound status rows (built server-side from a minimal {type, key} base) no longer raise KeyError. Bump 3.3.0 -> 3.4.0. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent 273ee88 commit ab28183

7 files changed

Lines changed: 233 additions & 12 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/core/dedupe.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ def normalize_file_path(path: str) -> str:
1111
@staticmethod
1212
def alert_key(alert: dict) -> tuple:
1313
return (
14-
alert["type"],
15-
alert["severity"],
14+
alert.get("type"),
15+
alert.get("severity"),
1616
alert.get("category"),
1717
Dedupe.normalize_file_path(alert.get("file")),
1818
alert.get("start"),
@@ -23,8 +23,8 @@ def alert_key(alert: dict) -> tuple:
2323
def consolidate_and_merge_alerts(package_group: List[Dict[str, Any]]) -> Dict[str, Any]:
2424
def alert_identity(alert: dict) -> tuple:
2525
return (
26-
alert["type"],
27-
alert["severity"],
26+
alert.get("type"),
27+
alert.get("severity"),
2828
alert.get("category"),
2929
Dedupe.normalize_file_path(alert.get("file")),
3030
alert.get("start"),
@@ -41,14 +41,17 @@ def alert_identity(alert: dict) -> tuple:
4141
identity = alert_identity(alert)
4242

4343
if identity not in alert_map:
44-
# Build alert dict with only fields that exist in the original alert
44+
# Build alert dict with only fields that exist in the original alert.
45+
# Use .get() for key/type/severity/action so synthetic status rows
46+
# (e.g. pendingScan/notFound), which are built server-side from a
47+
# minimal {type, key} base, don't raise KeyError here.
4548
consolidated_alert = {
46-
"key": alert["key"], # keep the first key seen
47-
"type": alert["type"],
48-
"severity": alert["severity"],
49+
"key": alert.get("key"), # keep the first key seen
50+
"type": alert.get("type"),
51+
"severity": alert.get("severity"),
4952
"releases": [release],
5053
"props": alert.get("props", []),
51-
"action": alert["action"]
54+
"action": alert.get("action")
5255
}
5356

5457
# Only include optional fields if they exist in the original alert

socketdev/exceptions.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,3 +78,22 @@ class APIBadGateway(APIFailure):
7878

7979
def __init__(self, *args):
8080
super().__init__(*args, status_code=502)
81+
82+
83+
class APIPartialResponse(APIFailure):
84+
"""Raised by ``purl.post(strict=True)`` when the batch response omits requested inputs.
85+
86+
The batch purl API is fail-open: input purls whose resolution/analysis has not
87+
completed are silently dropped from the response unless the caller opts in via
88+
``alerts=True`` (synthetic ``pendingScan``/``notFound`` rows) or ``poll=True`` (a
89+
bounded fail-closed wait). ``strict=True`` turns that silent omission into this
90+
explicit error so callers get a first-class "partial batch" signal without having
91+
to diff the response themselves.
92+
93+
The ``missing`` attribute holds the requested purls that were absent from the
94+
response (the HTTP call itself succeeded, so there is no status code).
95+
"""
96+
97+
def __init__(self, *args, missing=None):
98+
super().__init__(*args)
99+
self.missing = list(missing or [])

socketdev/purl/__init__.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import json
22
import urllib.parse
33
import warnings
4+
from typing import Optional
45
from socketdev.log import log
6+
from socketdev.exceptions import APIPartialResponse
57
from ..core.dedupe import Dedupe
68

79

@@ -14,8 +16,55 @@ def post(
1416
license: str = "false",
1517
components: list = None,
1618
org_slug: str = None,
19+
poll: Optional[bool] = None,
20+
timeout_sec: Optional[int] = None,
21+
alerts: Optional[bool] = None,
22+
purl_errors: Optional[bool] = None,
23+
strict: bool = False,
1724
**kwargs,
1825
) -> list:
26+
"""POST a batch of purls to the Socket batch purl endpoint and return deduped rows.
27+
28+
The batch purl API (``POST /v0/purl`` and ``POST /v0/orgs/{slug}/purl``) defaults
29+
to **fail-open**: any input purl whose resolution/analysis has not finished is
30+
**silently omitted** from the response. A naive caller therefore cannot tell
31+
"this version is clean" apart from "this version was dropped from the response".
32+
The parameters below opt into the server behaviors that make omissions visible.
33+
34+
Args:
35+
license: ``"true"``/``"false"`` — request license information (stringly-typed
36+
to match the query param the API expects).
37+
components: list of component dicts to score, e.g. ``[{"purl": "pkg:npm/lodash@4.18.1"}]``.
38+
org_slug: organization slug. When provided, routes to the org-scoped endpoint
39+
``POST /v0/orgs/{org_slug}/purl``; otherwise the deprecated ``POST /v0/purl``.
40+
poll: opt into a fail-closed bounded wait for pending analysis (``poll=True`` →
41+
``poll=true`` query param). ``None`` omits the param (server default).
42+
timeout_sec: bound in seconds for the ``poll`` wait (``→ timeoutSec``). The
43+
server may cap this via a feature flag. ``None`` omits the param.
44+
alerts: when ``True`` (``→ alerts=true``), the server emits synthetic
45+
``pendingScan``/``notFound`` status rows instead of silently omitting
46+
unresolved inputs, so callers can distinguish "no data yet" from "clean".
47+
purl_errors: when ``True`` (``→ purlErrors``), the server includes per-purl
48+
error rows for malformed/unresolvable inputs. ``None`` omits the param.
49+
strict: client-side guard. When ``True``, compares the ``purl`` of each
50+
requested component against the ``inputPurl``/``purl`` of the returned
51+
rows and raises :class:`~socketdev.exceptions.APIPartialResponse` (with a
52+
``missing`` list) if any requested purl is absent from the response. This
53+
surfaces partial batches even without ``alerts=True``. Only components that
54+
carry a ``purl`` string are checked.
55+
**kwargs: forwarded verbatim into the query string (back-compat passthrough for
56+
any params not yet promoted to first-class arguments).
57+
58+
Returns:
59+
A deduped list of result rows. When ``alerts=True``, unresolved inputs appear
60+
as synthetic rows carrying ``pendingScan``/``notFound`` alerts rather than being
61+
omitted. On a non-200 response, logs the error and returns ``[]`` (callers that
62+
need to fail closed should treat ``[]`` as an error).
63+
64+
Raises:
65+
APIPartialResponse: if ``strict=True`` and one or more requested component purls
66+
are missing from the response.
67+
"""
1968
if org_slug is None:
2069
warnings.warn(
2170
"Calling purl.post() without org_slug uses the deprecated POST /v0/purl endpoint. "
@@ -31,6 +80,16 @@ def post(
3180
query_args = {
3281
"license": license,
3382
}
83+
# Promote the typed params into query args only when explicitly set, so existing
84+
# callers keep the server's fail-open default (None => omit the param entirely).
85+
if poll is not None:
86+
query_args["poll"] = "true" if poll else "false"
87+
if timeout_sec is not None:
88+
query_args["timeoutSec"] = str(timeout_sec)
89+
if alerts is not None:
90+
query_args["alerts"] = "true" if alerts else "false"
91+
if purl_errors is not None:
92+
query_args["purlErrors"] = "true" if purl_errors else "false"
3493
if kwargs:
3594
query_args.update(kwargs)
3695
params = urllib.parse.urlencode(query_args)
@@ -48,8 +107,43 @@ def post(
48107
except json.JSONDecodeError:
49108
continue
50109
purl_deduped = Dedupe.dedupe(purl, batched=True)
110+
if strict:
111+
self._raise_on_missing(components, purl_deduped)
51112
return purl_deduped
52113

53114
log.error(f"Error posting {components} to the Purl API: {response.status_code}")
54115
log.error(response.text)
55116
return []
117+
118+
@staticmethod
119+
def _raise_on_missing(components: list, results: list) -> None:
120+
"""Raise APIPartialResponse if any requested component purl is absent from results.
121+
122+
Only components exposing a ``purl`` string are checked; the batch API echoes the
123+
request identifier back as ``inputPurl`` (falling back to ``purl``), so we compare
124+
against both.
125+
"""
126+
requested = [
127+
c["purl"]
128+
for c in components
129+
if isinstance(c, dict) and isinstance(c.get("purl"), str)
130+
]
131+
if not requested:
132+
return
133+
returned = set()
134+
for row in results:
135+
if not isinstance(row, dict):
136+
continue
137+
for field in ("inputPurl", "purl"):
138+
value = row.get(field)
139+
if isinstance(value, str):
140+
returned.add(value)
141+
missing = [purl for purl in requested if purl not in returned]
142+
if missing:
143+
raise APIPartialResponse(
144+
"purl.post(strict=True): the batch response omitted "
145+
f"{len(missing)} of {len(requested)} requested purls "
146+
"(fail-open: unresolved inputs are dropped unless alerts=True/poll=True): "
147+
f"{missing}",
148+
missing=missing,
149+
)

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"

tests/unit/test_all_endpoints_unit.py

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -401,6 +401,111 @@ def test_purl_post_unit_legacy_path(self):
401401
self.assertIn("/purl", call_args[0][1])
402402
self.assertNotIn("/orgs/", call_args[0][1])
403403

404+
def _mock_purl_ndjson(self, ndjson):
405+
"""Mock a 200 NDJSON purl response and return the mock."""
406+
mock_response = Mock()
407+
mock_response.status_code = 200
408+
mock_response.headers = {'content-type': 'application/x-ndjson'}
409+
mock_response.text = ndjson
410+
self.mock_requests.request.return_value = mock_response
411+
return mock_response
412+
413+
def test_purl_post_first_class_params_query_string(self):
414+
"""poll/timeout_sec/alerts/purl_errors map to the expected query params."""
415+
self._mock_purl_ndjson(
416+
'{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", '
417+
'"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}'
418+
)
419+
420+
self.sdk.purl.post(
421+
license="false",
422+
components=[{"purl": "pkg:npm/lodash@4.18.1"}],
423+
org_slug="test-org",
424+
poll=True,
425+
timeout_sec=120,
426+
alerts=True,
427+
purl_errors=False,
428+
)
429+
430+
url = self.mock_requests.request.call_args[0][1]
431+
self.assertIn("poll=true", url)
432+
self.assertIn("timeoutSec=120", url)
433+
self.assertIn("alerts=true", url)
434+
self.assertIn("purlErrors=false", url)
435+
436+
def test_purl_post_omits_unset_params(self):
437+
"""None-valued typed params are omitted so the API's fail-open default is preserved."""
438+
self._mock_purl_ndjson(
439+
'{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", '
440+
'"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}'
441+
)
442+
443+
self.sdk.purl.post(components=[{"purl": "pkg:npm/lodash@4.18.1"}], org_slug="test-org")
444+
445+
url = self.mock_requests.request.call_args[0][1]
446+
self.assertNotIn("poll=", url)
447+
self.assertNotIn("timeoutSec=", url)
448+
self.assertNotIn("alerts=", url)
449+
self.assertNotIn("purlErrors=", url)
450+
451+
def test_purl_post_synthetic_pending_scan_row(self):
452+
"""A synthetic pendingScan row (no severity/action) parses without raising KeyError."""
453+
# Synthetic status alerts are built server-side from a minimal {type, key} base.
454+
self._mock_purl_ndjson(
455+
'{"inputPurl": "pkg:npm/newpkg@0.0.1", "purl": "pkg:npm/newpkg@0.0.1", '
456+
'"type": "npm", "name": "newpkg", "version": "0.0.1", '
457+
'"alerts": [{"type": "pendingScan", "key": "abc123"}]}'
458+
)
459+
460+
result = self.sdk.purl.post(
461+
components=[{"purl": "pkg:npm/newpkg@0.0.1"}],
462+
org_slug="test-org",
463+
alerts=True,
464+
)
465+
466+
self.assertEqual(len(result), 1)
467+
alert = result[0]["alerts"][0]
468+
self.assertEqual(alert["type"], "pendingScan")
469+
self.assertEqual(alert["key"], "abc123")
470+
# Missing fields are surfaced as None rather than raising.
471+
self.assertIsNone(alert["severity"])
472+
self.assertIsNone(alert["action"])
473+
474+
def test_purl_post_strict_raises_on_missing(self):
475+
"""strict=True raises APIPartialResponse listing purls dropped from the response."""
476+
from socketdev.exceptions import APIPartialResponse
477+
478+
# Requested two purls; the fail-open API only returned one.
479+
self._mock_purl_ndjson(
480+
'{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", '
481+
'"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}'
482+
)
483+
484+
with self.assertRaises(APIPartialResponse) as ctx:
485+
self.sdk.purl.post(
486+
components=[
487+
{"purl": "pkg:npm/lodash@4.18.1"},
488+
{"purl": "pkg:npm/dropped@0.0.1"},
489+
],
490+
org_slug="test-org",
491+
strict=True,
492+
)
493+
self.assertEqual(ctx.exception.missing, ["pkg:npm/dropped@0.0.1"])
494+
495+
def test_purl_post_strict_passes_when_complete(self):
496+
"""strict=True returns normally when every requested purl is present."""
497+
self._mock_purl_ndjson(
498+
'{"inputPurl": "pkg:npm/lodash@4.18.1", "purl": "pkg:npm/lodash@4.18.1", '
499+
'"type": "npm", "name": "lodash", "version": "4.18.1", "valid": true, "alerts": []}'
500+
)
501+
502+
result = self.sdk.purl.post(
503+
components=[{"purl": "pkg:npm/lodash@4.18.1"}],
504+
org_slug="test-org",
505+
strict=True,
506+
)
507+
self.assertEqual(len(result), 1)
508+
404509
# Quota endpoints
405510
def test_quota_get_unit(self):
406511
"""Test quota retrieval."""

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)