socketsecurity 2.6.0__tar.gz → 2.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/CHANGELOG.md +22 -0
  2. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/PKG-INFO +1 -1
  3. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/pyproject.toml +1 -1
  4. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/__init__.py +1 -1
  5. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/__init__.py +183 -30
  6. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/conftest.py +21 -0
  7. socketsecurity-2.6.1/tests/core/test_diff_scan_polling.py +122 -0
  8. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_sdk_methods.py +26 -12
  9. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/uv.lock +1 -1
  10. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/CODEOWNERS +0 -0
  11. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/PULL_REQUEST_TEMPLATE/bug-fix.md +0 -0
  12. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/PULL_REQUEST_TEMPLATE/feature.md +0 -0
  13. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/PULL_REQUEST_TEMPLATE/improvement.md +0 -0
  14. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  15. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/actions/setup-docker/action.yml +0 -0
  16. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/actions/setup-hatch/action.yml +0 -0
  17. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/actions/setup-sfw/action.yml +0 -0
  18. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/dependabot.yml +0 -0
  19. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/dependency-review.yml +0 -0
  20. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/docker-stable.yml +0 -0
  21. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/e2e-test.yml +0 -0
  22. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/package-check.yml +0 -0
  23. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/pr-preview.yml +0 -0
  24. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/python-tests.yml +0 -0
  25. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/release.yml +0 -0
  26. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/workflows/version-check.yml +0 -0
  27. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.github/zizmor.yml +0 -0
  28. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.gitignore +0 -0
  29. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.hooks/sync_version.py +0 -0
  30. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.pre-commit-config.yaml +0 -0
  31. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/.python-version +0 -0
  32. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/CONTRIBUTING.md +0 -0
  33. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/Dockerfile +0 -0
  34. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/Dockerfile.preview +0 -0
  35. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/LICENSE +0 -0
  36. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/Makefile +0 -0
  37. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/README.md +0 -0
  38. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/docs/ci-cd.md +0 -0
  39. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/docs/cli-reference.md +0 -0
  40. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/docs/development.md +0 -0
  41. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/docs/troubleshooting.md +0 -0
  42. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/examples/config/sarif-dashboard-parity.json +0 -0
  43. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/examples/config/sarif-dashboard-parity.toml +0 -0
  44. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/examples/config/sarif-diff-ci-cd.json +0 -0
  45. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/examples/config/sarif-diff-ci-cd.toml +0 -0
  46. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/examples/config/sarif-instance-detail.json +0 -0
  47. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/examples/config/sarif-instance-detail.toml +0 -0
  48. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/instructions/gitlab-commit-status/uat.md +0 -0
  49. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/pytest.ini +0 -0
  50. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/scripts/build_container.sh +0 -0
  51. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/scripts/build_container_flexible.sh +0 -0
  52. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/scripts/deploy-test-docker.sh +0 -0
  53. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/scripts/deploy-test-pypi.sh +0 -0
  54. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/scripts/docker-entrypoint.sh +0 -0
  55. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/scripts/run.sh +0 -0
  56. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/session.md +0 -0
  57. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socket.yml +0 -0
  58. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/config.py +0 -0
  59. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/alert_selection.py +0 -0
  60. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/classes.py +0 -0
  61. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/cli_client.py +0 -0
  62. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/cli_run.py +0 -0
  63. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/exceptions.py +0 -0
  64. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/git_interface.py +0 -0
  65. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/helper/__init__.py +0 -0
  66. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/helper/socket_facts_loader.py +0 -0
  67. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/lazy_file_loader.py +0 -0
  68. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/log_uploader.py +0 -0
  69. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/logging.py +0 -0
  70. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/messages.py +0 -0
  71. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/resource_utils.py +0 -0
  72. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/scm/__init__.py +0 -0
  73. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/scm/base.py +0 -0
  74. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/scm/client.py +0 -0
  75. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/scm/github.py +0 -0
  76. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/scm/gitlab.py +0 -0
  77. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/scm_comments.py +0 -0
  78. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/socket_config.py +0 -0
  79. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/streaming.py +0 -0
  80. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/tools/reachability.py +0 -0
  81. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/core/utils.py +0 -0
  82. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/fossa_compat.py +0 -0
  83. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/output.py +0 -0
  84. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/__init__.py +0 -0
  85. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/base.py +0 -0
  86. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/formatters/__init__.py +0 -0
  87. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/formatters/slack.py +0 -0
  88. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/jira.py +0 -0
  89. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/manager.py +0 -0
  90. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/slack.py +0 -0
  91. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/teams.py +0 -0
  92. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/plugins/webhook.py +0 -0
  93. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/socketsecurity/socketcli.py +0 -0
  94. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/__init__.py +0 -0
  95. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/create_diff_input.json +0 -0
  96. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_diff_alerts.py +0 -0
  97. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_diff_generation.py +0 -0
  98. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_facts_compression.py +0 -0
  99. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_has_manifest_files.py +0 -0
  100. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_package_and_alerts.py +0 -0
  101. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/core/test_supporting_methods.py +0 -0
  102. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/create_response.json +0 -0
  103. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/diff/stream_diff.json +0 -0
  104. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/diff/stream_diff_full.json +0 -0
  105. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/head_scan/metadata.json +0 -0
  106. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/head_scan/stream_scan.json +0 -0
  107. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/head_scan/stream_scan_full.json +0 -0
  108. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/new_scan/metadata.json +0 -0
  109. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/fullscans/new_scan/stream_scan.json +0 -0
  110. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/repos/repo_info_error.json +0 -0
  111. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/repos/repo_info_no_head.json +0 -0
  112. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/repos/repo_info_success.json +0 -0
  113. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/data/settings/security-policy.json +0 -0
  114. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/fixtures/simple-npm/index.js +0 -0
  115. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/fixtures/simple-npm/package.json +0 -0
  116. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/fixtures/simple-pypi/requirements.txt +0 -0
  117. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/reach-facts-probe.sh +0 -0
  118. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/validate-gitlab.sh +0 -0
  119. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/validate-json.sh +0 -0
  120. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/validate-reachability.sh +0 -0
  121. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/validate-sarif.sh +0 -0
  122. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/e2e/validate-scan.sh +0 -0
  123. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/fixtures/fossa/README.md +0 -0
  124. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/fixtures/fossa/fossa-analyze-empty.json +0 -0
  125. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/fixtures/fossa/fossa-analyze-populated.json +0 -0
  126. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/fixtures/fossa/fossa-sbom-empty-deep.json +0 -0
  127. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/fixtures/fossa/fossa-sbom-populated.json +0 -0
  128. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/__init__.py +0 -0
  129. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_alert_selection.py +0 -0
  130. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_cli_config.py +0 -0
  131. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_cli_run.py +0 -0
  132. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_client.py +0 -0
  133. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_config.py +0 -0
  134. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_dependency_overview.py +0 -0
  135. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_disable_ignore.py +0 -0
  136. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_exclude_paths.py +0 -0
  137. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_fossa_compat.py +0 -0
  138. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_fossa_parity.py +0 -0
  139. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_full_scan_retry.py +0 -0
  140. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_gitlab_auth.py +0 -0
  141. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_gitlab_auth_fallback.py +0 -0
  142. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_gitlab_commit_status.py +0 -0
  143. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_gitlab_format.py +0 -0
  144. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_ignore_telemetry_filtering.py +0 -0
  145. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_include_dirs.py +0 -0
  146. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_log_uploader.py +0 -0
  147. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_output.py +0 -0
  148. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_reachability.py +0 -0
  149. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_slack_plugin.py +0 -0
  150. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_socketcli.py +0 -0
  151. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_streaming.py +0 -0
  152. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/tests/unit/test_tier1_finalize.py +0 -0
  153. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/workflows/bitbucket-pipelines.yml +0 -0
  154. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/workflows/buildkite.yml +0 -0
  155. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/workflows/github-actions.yml +0 -0
  156. {socketsecurity-2.6.0 → socketsecurity-2.6.1}/workflows/gitlab-ci.yml +0 -0
@@ -1,5 +1,27 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.6.1
4
+
5
+ ### Changed: scan comparison now polls the diff-scans endpoints
6
+
7
+ - Diff mode no longer holds a single idle HTTP connection open while the API
8
+ computes the scan comparison. The CLI now creates a diff-scan resource
9
+ (`POST /orgs/{org}/diff-scans/from-ids`) and polls
10
+ `GET /orgs/{org}/diff-scans/{id}?cached=true` with short, bounded requests
11
+ until the comparison is ready (HTTP 200 instead of 202). This fixes
12
+ intermittent `Connection reset by peer` failures on the final comparison
13
+ step when scans take several minutes to compare and network middleboxes
14
+ (e.g. Azure NAT gateways, which default to a 4-minute TCP idle timeout)
15
+ reap the idle connection.
16
+ - Duplicate scan pairs are resolved after an HTTP 409 and then polled through
17
+ the same cached endpoint. This avoids automatically following the API's 302
18
+ duplicate redirect with an uncached, potentially long-lived GET request.
19
+ - The change is transparent: no flags or workflow changes are needed. If the
20
+ org API token is missing the `diff-scans:create`, `diff-scans:list` or
21
+ `full-scans:list` scopes — or the new flow fails for any other reason — the
22
+ CLI logs a warning and falls back to the legacy streaming comparison.
23
+ - Requires the pinned `socketdev==3.5.0` SDK.
24
+
3
25
  ## 2.6.0
4
26
 
5
27
  ### Changed: pin all Python dependencies
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: socketsecurity
3
- Version: 2.6.0
3
+ Version: 2.6.1
4
4
  Summary: Socket Security CLI for CI/CD
5
5
  Project-URL: Homepage, https://socket.dev
6
6
  Author-email: Douglas Coburn <douglas@socket.dev>
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "socketsecurity"
9
- version = "2.6.0"
9
+ version = "2.6.1"
10
10
  requires-python = ">= 3.11"
11
11
  license = {"file" = "LICENSE"}
12
12
  dependencies = [
@@ -1,3 +1,3 @@
1
1
  __author__ = 'socket.dev'
2
- __version__ = '2.6.0'
2
+ __version__ = '2.6.1'
3
3
  USER_AGENT = f'SocketPythonCLI/{__version__}'
@@ -15,7 +15,7 @@ if TYPE_CHECKING:
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,6 +92,25 @@ FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS = (10.0, 30.0, None)
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. 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,120 @@ class Core:
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
+ ) -> 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
+
1306
1439
  def get_added_and_removed_packages(
1307
1440
  self,
1308
1441
  head_full_scan_id: str,
@@ -1315,8 +1448,12 @@ class Core:
1315
1448
  Args:
1316
1449
  head_full_scan_id: Previous scan (maybe None if first scan)
1317
1450
  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).
1320
1457
 
1321
1458
  Defaults to ``False`` on purpose. The diff endpoint exists to
1322
1459
  compare alerts between two scans; the license fields it can embed
@@ -1343,39 +1480,55 @@ class Core:
1343
1480
 
1344
1481
  log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}")
1345
1482
  diff_start = time.time()
1483
+ diff_artifacts = None
1346
1484
  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
1355
1488
  )
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
1366
1519
 
1367
1520
  diff_end = time.time()
1368
1521
  log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds")
1369
1522
  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
1379
1532
 
1380
1533
  added_packages: Dict[str, Package] = {}
1381
1534
  removed_packages: Dict[str, Package] = {}
@@ -87,6 +87,22 @@ def stream_diff_response(data_dir, load_json):
87
87
  })
88
88
 
89
89
 
90
+ @pytest.fixture
91
+ def diff_scan_get_response(data_dir, load_json):
92
+ """GET /orgs/{org}/diff-scans/{id} response built from the stream_diff fixture.
93
+
94
+ The diff-scans endpoint returns the same artifact shape as the legacy
95
+ streaming diff, wrapped in a diff_scan object.
96
+ """
97
+ json_data = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json")
98
+ return {
99
+ "diff_scan": {
100
+ "id": "diff-scan-123",
101
+ "artifacts": json_data["data"]["artifacts"],
102
+ }
103
+ }
104
+
105
+
90
106
 
91
107
 
92
108
 
@@ -138,6 +154,7 @@ def mock_sdk_with_responses(
138
154
  new_scan_metadata,
139
155
  new_scan_stream,
140
156
  stream_diff_response,
157
+ diff_scan_get_response,
141
158
  create_full_scan_response,
142
159
  ):
143
160
  sdk = mock_socket_sdk.return_value
@@ -173,4 +190,8 @@ def mock_sdk_with_responses(
173
190
  lambda org_slug, head_id, new_id, **kwargs: stream_diff_response
174
191
  )
175
192
 
193
+ # Diff-scans endpoints (primary scan-comparison path)
194
+ sdk.diffscans.create_from_ids.return_value = {"diff_scan": {"id": "diff-scan-123"}}
195
+ sdk.diffscans.get.return_value = diff_scan_get_response
196
+
176
197
  return sdk
@@ -0,0 +1,122 @@
1
+ """Tests for the diff-scans polling scan comparison.
2
+
3
+ The comparison must never hold an idle connection open: it creates a diff-scan
4
+ resource and polls the cached endpoint (202 while processing, 200 when ready),
5
+ falling back to the legacy streaming diff if the new flow is unavailable.
6
+ """
7
+ import pytest
8
+ from socketdev.exceptions import APIConnectionError, APIFailure
9
+
10
+ import socketsecurity.core as core_module
11
+ from socketsecurity.core import Core
12
+ from socketsecurity.core.socket_config import SocketConfig
13
+
14
+
15
+ @pytest.fixture
16
+ def core(mock_sdk_with_responses):
17
+ config = SocketConfig(api_key="test_key")
18
+ return Core(config=config, sdk=mock_sdk_with_responses)
19
+
20
+
21
+ @pytest.fixture
22
+ def no_sleep(mocker):
23
+ return mocker.patch("socketsecurity.core.time.sleep")
24
+
25
+
26
+ def test_polls_until_diff_scan_ready(core, diff_scan_get_response, no_sleep):
27
+ """202 processing responses are polled through until the 200 result arrives."""
28
+ processing = {"status": "processing", "id": "diff-scan-123"}
29
+ core.sdk.diffscans.get.side_effect = [processing, processing, diff_scan_get_response]
30
+
31
+ artifacts = core.get_diff_scan_artifacts("head", "new")
32
+
33
+ assert core.sdk.diffscans.get.call_count == 3
34
+ assert no_sleep.call_count == 2 # slept between polls, never during them
35
+ assert len(artifacts.added) > 0
36
+
37
+
38
+ def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeypatch):
39
+ """The poll interval grows toward the max so long comparisons stay quota-friendly."""
40
+ monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS", 4.0)
41
+ monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS", 10.0)
42
+ processing = {"status": "processing", "id": "diff-scan-123"}
43
+ core.sdk.diffscans.get.side_effect = [processing] * 4 + [diff_scan_get_response]
44
+
45
+ core.get_diff_scan_artifacts("head", "new")
46
+
47
+ waits = [call.args[0] for call in no_sleep.call_args_list]
48
+ assert waits == [4.0, 6.0, 9.0, 10.0] # 1.5x backoff, capped at the max
49
+
50
+
51
+ def test_transient_poll_error_is_retried(core, diff_scan_get_response, no_sleep):
52
+ """A dropped poll doesn't abandon the flow - the diff keeps computing server-side."""
53
+ core.sdk.diffscans.get.side_effect = [APIConnectionError("reset"), diff_scan_get_response]
54
+
55
+ artifacts = core.get_diff_scan_artifacts("head", "new")
56
+
57
+ assert core.sdk.diffscans.get.call_count == 2
58
+ assert len(artifacts.added) > 0
59
+
60
+
61
+ def test_non_transient_poll_error_raises(core, no_sleep):
62
+ """Deterministic API errors (e.g. 403 missing scopes) propagate to the caller."""
63
+ core.sdk.diffscans.get.side_effect = APIFailure("forbidden", status_code=403)
64
+
65
+ with pytest.raises(APIFailure):
66
+ core.get_diff_scan_artifacts("head", "new")
67
+
68
+
69
+ def test_poll_timeout_raises(core, no_sleep, monkeypatch):
70
+ """A diff scan that never completes hits the polling backstop."""
71
+ monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_TIMEOUT_SECONDS", 0.0)
72
+ core.sdk.diffscans.get.return_value = {"status": "processing", "id": "diff-scan-123"}
73
+
74
+ with pytest.raises(Exception, match="Timed out waiting for diff scan"):
75
+ core.get_diff_scan_artifacts("head", "new")
76
+
77
+
78
+ def test_duplicate_conflict_uses_cached_polling(core, diff_scan_get_response):
79
+ """A duplicate is resolved explicitly so the SDK cannot follow an uncached redirect."""
80
+ core.sdk.diffscans.create_from_ids.side_effect = APIFailure(
81
+ "duplicate", status_code=409
82
+ )
83
+ core.sdk.diffscans.list.return_value = {
84
+ "results": [{"id": "existing-diff-scan"}],
85
+ }
86
+
87
+ artifacts = core.get_diff_scan_artifacts("head", "new")
88
+
89
+ create_params = core.sdk.diffscans.create_from_ids.call_args.args[1]
90
+ assert "on_duplicate" not in create_params
91
+ core.sdk.diffscans.list.assert_called_once_with(
92
+ core.config.org_slug,
93
+ params={
94
+ "before_full_scan_id": "head",
95
+ "after_full_scan_id": "new",
96
+ "per_page": 1,
97
+ },
98
+ )
99
+ core.sdk.diffscans.get.assert_called_once_with(
100
+ core.config.org_slug,
101
+ "existing-diff-scan",
102
+ params={"cached": "true"},
103
+ )
104
+ assert len(artifacts.added) > 0
105
+
106
+
107
+ def test_fallback_to_streaming_diff_on_failure(core):
108
+ """If the diff-scans flow fails (e.g. token missing the diff-scans scopes),
109
+ the comparison falls back to the legacy streaming diff transparently."""
110
+ core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403)
111
+
112
+ added, removed, all_packages = core.get_added_and_removed_packages("head", "new")
113
+
114
+ core.sdk.fullscans.stream_diff.assert_called_once_with(
115
+ core.config.org_slug,
116
+ "head",
117
+ "new",
118
+ use_types=True,
119
+ include_license_details="false",
120
+ )
121
+ assert "dp3" in added
122
+ assert "dp2" in removed
@@ -225,19 +225,27 @@ def test_get_added_and_removed_packages(core):
225
225
  """Test getting added and removed packages between two scans"""
226
226
  # Get two different scans to compare
227
227
  added, removed, all_packages = core.get_added_and_removed_packages("head", "new")
228
-
229
- # Verify SDK was called correctly.
230
- # include_license_details defaults to "false": the diff path never consumes
231
- # embedded license data (license artifacts come from the PURL endpoint), so
232
- # requesting it only bloats the response and risks the truncation
233
- # crash on large repos.
234
- core.sdk.fullscans.stream_diff.assert_called_once_with(
228
+
229
+ # Verify SDK was called correctly: the comparison goes through the diff-scans
230
+ # endpoints (create + poll) rather than the legacy streaming diff, so no
231
+ # connection is left idle while the backend computes.
232
+ create_args = core.sdk.diffscans.create_from_ids.call_args
233
+ assert create_args[0][0] == core.config.org_slug
234
+ create_params = create_args[0][1]
235
+ assert create_params["before"] == "head"
236
+ assert create_params["after"] == "new"
237
+ assert "on_duplicate" not in create_params
238
+
239
+ # cached=true is the polling contract (202 while computing, 200 when ready).
240
+ # No omit_license_details param: the API ignores it for cached reads (cached
241
+ # results always embed license details), so sending it would only suggest a
242
+ # leanness guarantee this path doesn't have.
243
+ core.sdk.diffscans.get.assert_called_once_with(
235
244
  core.config.org_slug,
236
- "head",
237
- "new",
238
- use_types=True,
239
- include_license_details="false",
245
+ "diff-scan-123",
246
+ params={"cached": "true"},
240
247
  )
248
+ core.sdk.fullscans.stream_diff.assert_not_called()
241
249
 
242
250
  # Verify the results
243
251
  # Added packages
@@ -252,7 +260,13 @@ def test_get_added_and_removed_packages(core):
252
260
  assert "pypi/direct_package_1@1.6.0" in all_packages # Unchanged package is in full package map
253
261
 
254
262
  def test_get_added_and_removed_packages_license_override(core):
255
- """The include_license_details override seam still works when explicitly requested."""
263
+ """include_license_details only governs the legacy fallback path now: the
264
+ diff-scans path always receives embedded license details (the API ignores
265
+ omit_license_details for cached reads), so the seam must survive through to
266
+ the stream_diff call when the primary path is unavailable."""
267
+ from socketdev.exceptions import APIFailure
268
+
269
+ core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403)
256
270
  core.get_added_and_removed_packages("head", "new", include_license_details=True)
257
271
 
258
272
  core.sdk.fullscans.stream_diff.assert_called_once_with(
@@ -1282,7 +1282,7 @@ wheels = [
1282
1282
 
1283
1283
  [[package]]
1284
1284
  name = "socketsecurity"
1285
- version = "2.6.0"
1285
+ version = "2.6.1"
1286
1286
  source = { editable = "." }
1287
1287
  dependencies = [
1288
1288
  { name = "beautifulsoup4" },
File without changes
File without changes
File without changes