|
15 | 15 | from socketsecurity.config import CliConfig |
16 | 16 | from socketdev import socketdev |
17 | 17 | from socketdev.exceptions import APIFailure |
18 | | -from socketdev.fullscans import FullScanParams, SocketArtifact |
| 18 | +from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact |
19 | 19 | from socketdev.org import Organization |
20 | 20 | from socketdev.repos import RepositoryInfo |
21 | 21 | import copy |
|
92 | 92 | FULL_SCAN_UPLOAD_MAX_ATTEMPTS = len(FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS) |
93 | 93 | FULL_SCAN_UPLOAD_BACKOFF_JITTER_SECONDS = 2.0 |
94 | 94 |
|
| 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 (CE-354). 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 | + |
95 | 114 |
|
96 | 115 | def _humanize_alert_type(alert_type: str) -> str: |
97 | 116 | """Convert a camelCase/PascalCase alert type into a Title-Cased label. |
@@ -1303,6 +1322,93 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in |
1303 | 1322 |
|
1304 | 1323 | return packages |
1305 | 1324 |
|
| 1325 | + def get_diff_scan_artifacts( |
| 1326 | + self, |
| 1327 | + head_full_scan_id: str, |
| 1328 | + new_full_scan_id: str, |
| 1329 | + include_license_details: bool = False |
| 1330 | + ) -> DiffArtifacts: |
| 1331 | + """Compare two full scans via the diff-scans endpoints, polling for the result. |
| 1332 | +
|
| 1333 | + Creates a diff-scan resource from the two full scan IDs, then polls |
| 1334 | + ``GET /orgs/{org}/diff-scans/{id}?cached=true`` until the API returns the |
| 1335 | + computed comparison (200) instead of a processing status (202). Unlike the |
| 1336 | + legacy ``fullscans.stream_diff`` call, no request is ever left idle while |
| 1337 | + the backend computes, so the comparison survives network idle timeouts |
| 1338 | + (CE-354). See the DIFF_SCAN_POLL_* constants for the polling policy. |
| 1339 | +
|
| 1340 | + Requires an org token with the ``diff-scans:create``, ``diff-scans:list`` |
| 1341 | + and ``full-scans:list`` scopes; callers are expected to catch failures and |
| 1342 | + fall back to the legacy streaming comparison. |
| 1343 | +
|
| 1344 | + Args: |
| 1345 | + head_full_scan_id: The before/base full scan ID |
| 1346 | + new_full_scan_id: The after/head full scan ID |
| 1347 | + include_license_details: Whether to keep embedded per-package license |
| 1348 | + details in the response (see get_added_and_removed_packages for |
| 1349 | + why this defaults to False) |
| 1350 | +
|
| 1351 | + Returns: |
| 1352 | + DiffArtifacts with the added/removed/unchanged/replaced/updated lists |
| 1353 | + """ |
| 1354 | + create_params = { |
| 1355 | + "before": head_full_scan_id, |
| 1356 | + "after": new_full_scan_id, |
| 1357 | + "description": f"Socket Security CLI v{__version__} scan comparison", |
| 1358 | + # A rerun against the same pair of scans returns the existing diff |
| 1359 | + # scan instead of failing with a 409. |
| 1360 | + "on_duplicate": "redirect", |
| 1361 | + } |
| 1362 | + result = self.sdk.diffscans.create_from_ids(self.config.org_slug, create_params) |
| 1363 | + diff_scan = result.get("diff_scan") or {} |
| 1364 | + diff_scan_id = diff_scan.get("id") |
| 1365 | + if not diff_scan_id: |
| 1366 | + raise Exception(f"Error creating diff scan: unexpected response: {str(result)[:500]}") |
| 1367 | + # An on_duplicate redirect can land on an already-computed diff scan, in |
| 1368 | + # which case the create response already carries the artifacts. |
| 1369 | + artifacts_dict = diff_scan.get("artifacts") |
| 1370 | + |
| 1371 | + poll_params = { |
| 1372 | + "cached": "true", |
| 1373 | + "omit_license_details": "false" if include_license_details else "true", |
| 1374 | + } |
| 1375 | + deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS |
| 1376 | + interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS |
| 1377 | + while artifacts_dict is None: |
| 1378 | + try: |
| 1379 | + response = self.sdk.diffscans.get(self.config.org_slug, diff_scan_id, params=poll_params) |
| 1380 | + except APIFailure as error: |
| 1381 | + if not error.is_transient_error(): |
| 1382 | + raise |
| 1383 | + # A dropped/timed-out poll is retryable: the diff scan keeps |
| 1384 | + # computing server-side regardless of what happens to any one poll. |
| 1385 | + log.warning( |
| 1386 | + f"Transient error polling diff scan {diff_scan_id} " |
| 1387 | + f"({type(error).__name__}), retrying in {interval:.0f}s" |
| 1388 | + ) |
| 1389 | + response = {"status": "processing"} |
| 1390 | + if response.get("status") != "processing": |
| 1391 | + scan = response.get("diff_scan") or {} |
| 1392 | + if scan.get("artifacts") is None: |
| 1393 | + raise Exception( |
| 1394 | + f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}" |
| 1395 | + ) |
| 1396 | + artifacts_dict = scan["artifacts"] |
| 1397 | + break |
| 1398 | + if time.monotonic() >= deadline: |
| 1399 | + raise Exception( |
| 1400 | + f"Timed out waiting for diff scan {diff_scan_id} after " |
| 1401 | + f"{DIFF_SCAN_POLL_TIMEOUT_SECONDS:.0f} seconds" |
| 1402 | + ) |
| 1403 | + log.debug(f"Diff scan {diff_scan_id} still processing, polling again in {interval:.0f}s") |
| 1404 | + time.sleep(interval) |
| 1405 | + interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS) |
| 1406 | + |
| 1407 | + return DiffArtifacts.from_dict({ |
| 1408 | + key: artifacts_dict.get(key) or [] |
| 1409 | + for key in ("added", "removed", "unchanged", "replaced", "updated") |
| 1410 | + }) |
| 1411 | + |
1306 | 1412 | def get_added_and_removed_packages( |
1307 | 1413 | self, |
1308 | 1414 | head_full_scan_id: str, |
@@ -1343,39 +1449,56 @@ def get_added_and_removed_packages( |
1343 | 1449 |
|
1344 | 1450 | log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}") |
1345 | 1451 | diff_start = time.time() |
| 1452 | + diff_artifacts = None |
1346 | 1453 | 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 |
| 1454 | + diff_artifacts = self.get_diff_scan_artifacts( |
| 1455 | + head_full_scan_id, |
| 1456 | + new_full_scan_id, |
| 1457 | + include_license_details=include_license_details |
1355 | 1458 | ) |
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 |
| 1459 | + except Exception as error: |
| 1460 | + # SDK error messages can span many lines (path + response headers); the |
| 1461 | + # first line carries the status, which is all the warning needs. |
| 1462 | + error_summary = str(error).strip().splitlines()[0] if str(error).strip() else "" |
| 1463 | + log.warning( |
| 1464 | + f"Diff scan comparison failed with {type(error).__name__}({error_summary}), " |
| 1465 | + "falling back to the streaming scan comparison" |
| 1466 | + ) |
| 1467 | + |
| 1468 | + if diff_artifacts is None: |
| 1469 | + try: |
| 1470 | + diff_artifacts = ( |
| 1471 | + self.sdk.fullscans.stream_diff( |
| 1472 | + self.config.org_slug, |
| 1473 | + head_full_scan_id, |
| 1474 | + new_full_scan_id, |
| 1475 | + use_types=True, |
| 1476 | + include_license_details=str(include_license_details).lower() |
| 1477 | + ).data.artifacts |
| 1478 | + ) |
| 1479 | + except APIFailure as e: |
| 1480 | + log.error(f"API Error: {e}") |
| 1481 | + if self.cli_config and self.cli_config.disable_blocking: |
| 1482 | + sys.exit(0) |
| 1483 | + sys.exit(1) |
| 1484 | + except Exception as e: |
| 1485 | + import traceback |
| 1486 | + log.error(f"Error getting diff report: {str(e)}") |
| 1487 | + log.error(f"Stack trace:\n{traceback.format_exc()}") |
| 1488 | + raise |
1366 | 1489 |
|
1367 | 1490 | diff_end = time.time() |
1368 | 1491 | log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") |
1369 | 1492 | 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 |
| 1493 | + log.info(f"Added: {len(diff_artifacts.added)}") |
| 1494 | + log.info(f"Removed: {len(diff_artifacts.removed)}") |
| 1495 | + log.info(f"Unchanged: {len(diff_artifacts.unchanged)}") |
| 1496 | + log.info(f"Replaced: {len(diff_artifacts.replaced)}") |
| 1497 | + log.info(f"Updated: {len(diff_artifacts.updated)}") |
| 1498 | + |
| 1499 | + added_artifacts = diff_artifacts.added + diff_artifacts.updated |
| 1500 | + removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced |
| 1501 | + unchanged_artifacts = diff_artifacts.unchanged |
1379 | 1502 |
|
1380 | 1503 | added_packages: Dict[str, Package] = {} |
1381 | 1504 | removed_packages: Dict[str, Package] = {} |
|
0 commit comments