1515 from socketsecurity .config import CliConfig
1616from socketdev import socketdev
1717from socketdev .exceptions import APIFailure
18- from socketdev .fullscans import FullScanParams , SocketArtifact
18+ from socketdev .fullscans import DiffArtifacts , FullScanParams , SocketArtifact
1919from socketdev .org import Organization
2020from socketdev .repos import RepositoryInfo
2121import copy
9292FULL_SCAN_UPLOAD_MAX_ATTEMPTS = len (FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS )
9393FULL_SCAN_UPLOAD_BACKOFF_JITTER_SECONDS = 2.0
9494
95+ # Diff-scan polling policy. The legacy scan comparison (fullscans.stream_diff) holds a
96+ # single HTTP connection open, fully idle, while the backend computes the diff; network
97+ # middleboxes with TCP idle timeouts (notably Azure NAT gateways, which default to
98+ # 4 minutes) kill that connection with a RST, surfacing as an intermittent
99+ # ConnectionResetError on large scans. The diff-scans flow instead creates a
100+ # diff-scan resource and polls its cached endpoint with short bounded requests: the API
101+ # answers 202 while the comparison is still computing and 200 with the result once it is
102+ # ready, so no connection is ever idle long enough to be reaped.
103+ #
104+ # Each poll consumes 1 unit of API quota, so the interval backs off toward
105+ # DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS to stay quota-friendly on comparisons that take
106+ # minutes to compute. The timeout is a backstop against a diff scan that never
107+ # completes; on expiry (or any other failure of this flow) the caller falls back to the
108+ # legacy streaming comparison rather than failing the scan outright.
109+ DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS = 5.0
110+ DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS = 30.0
111+ DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5
112+ DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0
113+
95114
96115def _humanize_alert_type (alert_type : str ) -> str :
97116 """Convert a camelCase/PascalCase alert type into a Title-Cased label.
@@ -1303,6 +1322,120 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in
13031322
13041323 return packages
13051324
1325+ def get_diff_scan_artifacts (
1326+ self ,
1327+ head_full_scan_id : str ,
1328+ new_full_scan_id : str
1329+ ) -> DiffArtifacts :
1330+ """Compare two full scans via the diff-scans endpoints, polling for the result.
1331+
1332+ Creates a diff-scan resource from the two full scan IDs, then polls
1333+ ``GET /orgs/{org}/diff-scans/{id}?cached=true`` until the API returns the
1334+ computed comparison (200) instead of a processing status (202). Unlike the
1335+ legacy ``fullscans.stream_diff`` call, no request is ever left idle while
1336+ the backend computes, so the comparison survives network idle timeouts.
1337+ See the DIFF_SCAN_POLL_* constants for the polling policy.
1338+
1339+ Requires an org token with the ``diff-scans:create``, ``diff-scans:list``
1340+ and ``full-scans:list`` scopes; callers are expected to catch failures and
1341+ fall back to the legacy streaming comparison.
1342+
1343+ Note that cached diff-scan responses always embed per-package license
1344+ details (the API ignores ``omit_license_details`` when ``cached=true``),
1345+ so unlike the legacy streaming comparison there is no lean-response
1346+ option here; see the comment on ``poll_params`` below.
1347+
1348+ Args:
1349+ head_full_scan_id: The before/base full scan ID
1350+ new_full_scan_id: The after/head full scan ID
1351+
1352+ Returns:
1353+ DiffArtifacts with the added/removed/unchanged/replaced/updated lists
1354+ """
1355+ create_params = {
1356+ "before" : head_full_scan_id ,
1357+ "after" : new_full_scan_id ,
1358+ "description" : f"Socket Security CLI v{ __version__ } scan comparison" ,
1359+ }
1360+ try :
1361+ result = self .sdk .diffscans .create_from_ids (self .config .org_slug , create_params )
1362+ diff_scan = result .get ("diff_scan" ) or {}
1363+ response_summary = result
1364+ except APIFailure as error :
1365+ if error .status_code != 409 :
1366+ raise
1367+
1368+ # Do not use on_duplicate=redirect here. The SDK follows that 302
1369+ # automatically with a GET that lacks cached=true, which can leave
1370+ # the connection idle while an existing diff scan is still computing.
1371+ # Resolve the duplicate resource explicitly so every result fetch
1372+ # continues through the bounded cached polling path below.
1373+ existing = self .sdk .diffscans .list (
1374+ self .config .org_slug ,
1375+ params = {
1376+ "before_full_scan_id" : head_full_scan_id ,
1377+ "after_full_scan_id" : new_full_scan_id ,
1378+ "per_page" : 1 ,
1379+ },
1380+ )
1381+ matches = existing .get ("results" ) or []
1382+ diff_scan = matches [0 ] if matches else {}
1383+ response_summary = existing
1384+
1385+ diff_scan_id = diff_scan .get ("id" )
1386+ if not diff_scan_id :
1387+ raise Exception (
1388+ "Error creating or resolving diff scan: "
1389+ f"unexpected response: { str (response_summary )[:500 ]} "
1390+ )
1391+ artifacts_dict = diff_scan .get ("artifacts" )
1392+
1393+ # cached=true is the polling contract (202 while computing, 200 when
1394+ # ready). The API ignores omit_license_details when cached=true - cached
1395+ # results always embed license details - so there is no lean-response
1396+ # option on this path (unlike stream_diff with
1397+ # include_license_details=false, the lean-payload mitigation). If that extra
1398+ # payload ever gets a response truncated on a huge dependency tree,
1399+ # response.json() fails and the caller falls back to the legacy
1400+ # streaming comparison, which still requests the lean payload.
1401+ poll_params = {"cached" : "true" }
1402+ deadline = time .monotonic () + DIFF_SCAN_POLL_TIMEOUT_SECONDS
1403+ interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS
1404+ while artifacts_dict is None :
1405+ try :
1406+ response = self .sdk .diffscans .get (self .config .org_slug , diff_scan_id , params = poll_params )
1407+ except APIFailure as error :
1408+ if not error .is_transient_error ():
1409+ raise
1410+ # A dropped/timed-out poll is retryable: the diff scan keeps
1411+ # computing server-side regardless of what happens to any one poll.
1412+ log .warning (
1413+ f"Transient error polling diff scan { diff_scan_id } "
1414+ f"({ type (error ).__name__ } ), retrying in { interval :.0f} s"
1415+ )
1416+ response = {"status" : "processing" }
1417+ if response .get ("status" ) != "processing" :
1418+ scan = response .get ("diff_scan" ) or {}
1419+ if scan .get ("artifacts" ) is None :
1420+ raise Exception (
1421+ f"Error fetching diff scan { diff_scan_id } : unexpected response: { str (response )[:500 ]} "
1422+ )
1423+ artifacts_dict = scan ["artifacts" ]
1424+ break
1425+ if time .monotonic () >= deadline :
1426+ raise Exception (
1427+ f"Timed out waiting for diff scan { diff_scan_id } after "
1428+ f"{ DIFF_SCAN_POLL_TIMEOUT_SECONDS :.0f} seconds"
1429+ )
1430+ log .debug (f"Diff scan { diff_scan_id } still processing, polling again in { interval :.0f} s" )
1431+ time .sleep (interval )
1432+ interval = min (interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER , DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS )
1433+
1434+ return DiffArtifacts .from_dict ({
1435+ key : artifacts_dict .get (key ) or []
1436+ for key in ("added" , "removed" , "unchanged" , "replaced" , "updated" )
1437+ })
1438+
13061439 def get_added_and_removed_packages (
13071440 self ,
13081441 head_full_scan_id : str ,
@@ -1315,8 +1448,12 @@ def get_added_and_removed_packages(
13151448 Args:
13161449 head_full_scan_id: Previous scan (maybe None if first scan)
13171450 new_full_scan_id: New scan just created
1318- include_license_details: Whether to ask the diff endpoint to embed
1319- per-package license attribution/details in the response.
1451+ include_license_details: Whether to ask the *legacy streaming* diff
1452+ endpoint to embed per-package license attribution/details in the
1453+ response. Only consulted on the fallback path: the primary
1454+ diff-scans path always receives embedded license details, since
1455+ the API ignores ``omit_license_details`` for cached reads (see
1456+ get_diff_scan_artifacts).
13201457
13211458 Defaults to ``False`` on purpose. The diff endpoint exists to
13221459 compare alerts between two scans; the license fields it can embed
@@ -1343,39 +1480,55 @@ def get_added_and_removed_packages(
13431480
13441481 log .info (f"Comparing scans - Head scan ID: { head_full_scan_id } , New scan ID: { new_full_scan_id } " )
13451482 diff_start = time .time ()
1483+ diff_artifacts = None
13461484 try :
1347- diff_report = (
1348- self .sdk .fullscans .stream_diff (
1349- self .config .org_slug ,
1350- head_full_scan_id ,
1351- new_full_scan_id ,
1352- use_types = True ,
1353- include_license_details = str (include_license_details ).lower ()
1354- ).data
1485+ diff_artifacts = self .get_diff_scan_artifacts (
1486+ head_full_scan_id ,
1487+ new_full_scan_id
13551488 )
1356- except APIFailure as e :
1357- log .error (f"API Error: { e } " )
1358- if self .cli_config and self .cli_config .disable_blocking :
1359- sys .exit (0 )
1360- sys .exit (1 )
1361- except Exception as e :
1362- import traceback
1363- log .error (f"Error getting diff report: { str (e )} " )
1364- log .error (f"Stack trace:\n { traceback .format_exc ()} " )
1365- raise
1489+ except Exception as error :
1490+ # SDK error messages can span many lines (path + response headers); the
1491+ # first line carries the status, which is all the warning needs.
1492+ error_summary = str (error ).strip ().splitlines ()[0 ] if str (error ).strip () else ""
1493+ log .warning (
1494+ f"Diff scan comparison failed with { type (error ).__name__ } ({ error_summary } ), "
1495+ "falling back to the streaming scan comparison"
1496+ )
1497+
1498+ if diff_artifacts is None :
1499+ try :
1500+ diff_artifacts = (
1501+ self .sdk .fullscans .stream_diff (
1502+ self .config .org_slug ,
1503+ head_full_scan_id ,
1504+ new_full_scan_id ,
1505+ use_types = True ,
1506+ include_license_details = str (include_license_details ).lower ()
1507+ ).data .artifacts
1508+ )
1509+ except APIFailure as e :
1510+ log .error (f"API Error: { e } " )
1511+ if self .cli_config and self .cli_config .disable_blocking :
1512+ sys .exit (0 )
1513+ sys .exit (1 )
1514+ except Exception as e :
1515+ import traceback
1516+ log .error (f"Error getting diff report: { str (e )} " )
1517+ log .error (f"Stack trace:\n { traceback .format_exc ()} " )
1518+ raise
13661519
13671520 diff_end = time .time ()
13681521 log .info (f"Diff Report Gathered in { diff_end - diff_start :.2f} seconds" )
13691522 log .info ("Diff report artifact counts:" )
1370- log .info (f"Added: { len (diff_report . artifacts .added )} " )
1371- log .info (f"Removed: { len (diff_report . artifacts .removed )} " )
1372- log .info (f"Unchanged: { len (diff_report . artifacts .unchanged )} " )
1373- log .info (f"Replaced: { len (diff_report . artifacts .replaced )} " )
1374- log .info (f"Updated: { len (diff_report . artifacts .updated )} " )
1375-
1376- added_artifacts = diff_report . artifacts . added + diff_report . artifacts .updated
1377- removed_artifacts = diff_report . artifacts . removed + diff_report . artifacts .replaced
1378- unchanged_artifacts = diff_report . artifacts .unchanged
1523+ log .info (f"Added: { len (diff_artifacts .added )} " )
1524+ log .info (f"Removed: { len (diff_artifacts .removed )} " )
1525+ log .info (f"Unchanged: { len (diff_artifacts .unchanged )} " )
1526+ log .info (f"Replaced: { len (diff_artifacts .replaced )} " )
1527+ log .info (f"Updated: { len (diff_artifacts .updated )} " )
1528+
1529+ added_artifacts = diff_artifacts . added + diff_artifacts .updated
1530+ removed_artifacts = diff_artifacts . removed + diff_artifacts .replaced
1531+ unchanged_artifacts = diff_artifacts .unchanged
13791532
13801533 added_packages : Dict [str , Package ] = {}
13811534 removed_packages : Dict [str , Package ] = {}
0 commit comments