diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 2eea2fb..58239ff 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -19,10 +19,10 @@ inputs: runs: using: "composite" steps: - - uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0 + - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 if: inputs.enable-qemu == 'true' - - uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 - - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ inputs.dockerhub-username }} password: ${{ inputs.dockerhub-token }} diff --git a/.github/actions/setup-sfw/action.yml b/.github/actions/setup-sfw/action.yml index 456f90b..66b54ff 100644 --- a/.github/actions/setup-sfw/action.yml +++ b/.github/actions/setup-sfw/action.yml @@ -27,7 +27,7 @@ runs: using: "composite" steps: - if: ${{ inputs.python == 'true' || inputs.uv == 'true' }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index a1d2f29..777777b 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -80,7 +80,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.12' diff --git a/.github/workflows/package-check.yml b/.github/workflows/package-check.yml index b2e8409..d71083a 100644 --- a/.github/workflows/package-check.yml +++ b/.github/workflows/package-check.yml @@ -23,7 +23,7 @@ jobs: fetch-depth: 1 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml index 473a68a..3a2ab1a 100644 --- a/.github/workflows/pr-preview.yml +++ b/.github/workflows/pr-preview.yml @@ -107,7 +107,7 @@ jobs: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.12" diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 7713306..3471722 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -40,7 +40,7 @@ jobs: fetch-depth: 1 persist-credentials: false - name: 🐍 setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ env.PYTHON_VERSION }} - name: 🛠️ install deps @@ -76,7 +76,7 @@ jobs: fetch-depth: 1 persist-credentials: false - name: 🐍 setup python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" - name: 🚫 verify install is rejected on unsupported python diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5be9e26..62b2f2b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: with: fetch-depth: 0 persist-credentials: false - - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.13' @@ -66,7 +66,7 @@ jobs: - name: Publish to PyPI if: steps.version_check.outputs.pypi_exists != 'true' - uses: pypa/gh-action-pypi-publish@ba38be9e461d3875417946c167d0b5f3d385a247 # v1.14.1 + uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2 - name: Set up Docker publishing uses: ./.github/actions/setup-docker diff --git a/CHANGELOG.md b/CHANGELOG.md index 202c6ed..ecd3585 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,77 @@ # Changelog +## 2.6.5 + +### Changed: bump pinned @coana-tech/cli to 15.10.16 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.13` to + `15.10.16`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + +## 2.6.4 + +### Changed: bump pinned @coana-tech/cli to 15.10.13 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.4` to + `15.10.13`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + +### Changed: bump pinned GitPython to 3.1.59 + +- Bumped `GitPython` from `3.1.57` to `3.1.59`, picking up the security fixes + released in GitPython 3.1.58 and 3.1.59. None of the affected GitPython APIs + are used by this CLI. + +## 2.6.3 + +### Fixed: SBOM fetch failures no longer produce empty reports + +- `Core.get_sbom_data` now raises `APIFailure` when the full-scan stream fetch + fails, so the run exits through the CLI's API-error handling (exit code 3 by + default; `--disable-blocking` still exits 0) instead of writing empty + GitLab dependency-scanning, license, and SARIF reports. +- The underlying stream-parse failure was fixed in `socketdev` 3.4.2 (already + pinned to `3.5.0`): unrecognized purl types such as `generic` now resolve + instead of raising, and individual unparseable artifacts are skipped rather + than failing the whole response. + +### Changed: bump pinned @coana-tech/cli to 15.10.4 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.3` to + `15.10.4`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + +### Changed: dependency updates and dev/test dependency pinning + +- Bumped pinned runtime dependencies (`beautifulsoup4` `4.15.0`, + `markdown` `3.10.3`). +- Pinned the `test` and `dev` dependency groups to exact versions, extending + the exact-pinning policy introduced in 2.6.0 to all dependency groups. +- Updated GitHub Actions used in CI workflows. No effect on the installed + CLI. + +## 2.6.1 + +### Changed: scan comparison now polls the diff-scans endpoints + +- Diff mode no longer holds a single idle HTTP connection open while the API + computes the scan comparison. The CLI now creates a diff-scan resource + (`POST /orgs/{org}/diff-scans/from-ids`) and polls + `GET /orgs/{org}/diff-scans/{id}?cached=true` with short, bounded requests + until the comparison is ready (HTTP 200 instead of 202). This fixes + intermittent `Connection reset by peer` failures on the final comparison + step when scans take several minutes to compare and network middleboxes + (e.g. Azure NAT gateways, which default to a 4-minute TCP idle timeout) + reap the idle connection. +- Duplicate scan pairs are resolved after an HTTP 409 and then polled through + the same cached endpoint. This avoids automatically following the API's 302 + duplicate redirect with an uncached, potentially long-lived GET request. +- The change is transparent: no flags or workflow changes are needed. If the + org API token is missing the `diff-scans:create`, `diff-scans:list` or + `full-scans:list` scopes — or the new flow fails for any other reason — the + CLI logs a warning and falls back to the legacy streaming comparison. +- Requires the pinned `socketdev==3.5.0` SDK. + ## 2.6.0 ### Changed: pin all Python dependencies diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9f4ca88..736fe8b 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -275,7 +275,7 @@ If you don't want to provide the Socket API Token every time then you can use th | Parameter | Required | Default | Description | |:---------------------------------|:---------|:--------|:---------------------------------------------------------------------------------------------------------------------------| | `--reach` | False | False | Enable reachability analysis to identify which vulnerable functions are actually called by your code. Creates a full application reachability scan (`scan_type=socket_tier1`). | -| `--reach-version` | False | 15.10.3 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | +| `--reach-version` | False | 15.10.16 | Version of @coana-tech/cli to use. Defaults to the pinned version that ships with this CLI release, so the engine only changes when you upgrade the Socket CLI. Pass `latest` to always use the newest published version (opt-in auto-update), or an explicit version (e.g. `1.2.3`) to pin it. | | `--reach-analysis-timeout` | False | 10m | Timeout for each reachability analysis run, e.g. `90s`, `10m` or `1h`. Omitted by default, so coana applies its own default (`10m`). Alias: `--reach-timeout` | | `--reach-analysis-memory-limit` | False | 8GB | Memory limit for each reachability analysis run, e.g. `512MB` or `8GB`. Omitted by default, so coana applies its own default (`8GB`). Alias: `--reach-memory-limit` | | `--reach-concurrency` | False | 1 | Control parallel analysis execution (must be >= 1). Omitted by default, so coana applies its own default. | diff --git a/pyproject.toml b/pyproject.toml index 1b49c2c..48ae590 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,19 +6,19 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.0" +version = "2.6.5" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ "requests==2.34.2", "mdutils==1.8.1", "prettytable==3.18.0", - "GitPython==3.1.57", + "GitPython==3.1.59", "packaging==26.2", "python-dotenv==1.2.2", "socketdev==3.5.0", - "beautifulsoup4==4.14.3", - "markdown==3.10.2", + "beautifulsoup4==4.15.0", + "markdown==3.10.3", "brotli==1.2.0; platform_python_implementation == 'CPython'", "brotlicffi==1.2.0.1; platform_python_implementation != 'CPython'", ] @@ -40,18 +40,18 @@ classifiers = [ [project.optional-dependencies] test = [ - "pytest>=7.4.0", - "pytest-cov>=4.1.0", - "pytest-mock>=3.12.0", - "pytest-asyncio>=0.23.0", - "pytest-watch >=4.2.0" + "pytest==9.1.1", + "pytest-cov==7.1.0", + "pytest-mock==3.15.1", + "pytest-asyncio==1.4.0", + "pytest-watch==4.2.0" ] dev = [ - "ruff>=0.3.0", - "twine", # for building - "uv>=0.1.0", # for dependency management - "pre-commit", - "hatch" + "ruff==0.16.1", + "twine==7.0.0", # for building + "uv==0.12.0", # for dependency management + "pre-commit==4.6.1", + "hatch==1.17.1" ] [project.scripts] diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 2a2ecb9..5875d7c 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.0' +__version__ = '2.6.5' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index a372de4..7be2485 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -15,7 +15,7 @@ from socketsecurity.config import CliConfig from socketdev import socketdev from socketdev.exceptions import APIFailure -from socketdev.fullscans import FullScanParams, SocketArtifact +from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact from socketdev.org import Organization from socketdev.repos import RepositoryInfo import copy @@ -92,6 +92,25 @@ FULL_SCAN_UPLOAD_MAX_ATTEMPTS = len(FULL_SCAN_UPLOAD_BACKOFF_SCHEDULE_SECONDS) FULL_SCAN_UPLOAD_BACKOFF_JITTER_SECONDS = 2.0 +# Diff-scan polling policy. The legacy scan comparison (fullscans.stream_diff) holds a +# single HTTP connection open, fully idle, while the backend computes the diff; network +# middleboxes with TCP idle timeouts (notably Azure NAT gateways, which default to +# 4 minutes) kill that connection with a RST, surfacing as an intermittent +# ConnectionResetError on large scans. The diff-scans flow instead creates a +# diff-scan resource and polls its cached endpoint with short bounded requests: the API +# answers 202 while the comparison is still computing and 200 with the result once it is +# ready, so no connection is ever idle long enough to be reaped. +# +# Each poll consumes 1 unit of API quota, so the interval backs off toward +# DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS to stay quota-friendly on comparisons that take +# minutes to compute. The timeout is a backstop against a diff scan that never +# completes; on expiry (or any other failure of this flow) the caller falls back to the +# legacy streaming comparison rather than failing the scan outright. +DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS = 5.0 +DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS = 30.0 +DIFF_SCAN_POLL_BACKOFF_MULTIPLIER = 1.5 +DIFF_SCAN_POLL_TIMEOUT_SECONDS = 30 * 60.0 + def _humanize_alert_type(alert_type: str) -> str: """Convert a camelCase/PascalCase alert type into a Title-Cased label. @@ -154,9 +173,13 @@ def get_sbom_data(self, full_scan_id: str) -> Dict[str, SocketArtifact]: """Returns SBOM artifacts for a full scan keyed by artifact ID.""" response = self.sdk.fullscans.stream(self.config.org_slug, full_scan_id, use_types=True) if not response.success: - log.debug(f"Failed to get SBOM data for full-scan {full_scan_id}") - log.debug(response.message) - return {} + # Raise instead of returning {} so a failed fetch surfaces as an + # API error (exit code 3 by default) rather than empty reports. + log.error(f"Failed to get SBOM data for full-scan {full_scan_id}") + log.error(response.message) + raise APIFailure( + f"Failed to get SBOM data for full-scan {full_scan_id}: {response.message}" + ) if not hasattr(response, "artifacts") or not response.artifacts: return {} return response.artifacts @@ -1303,6 +1326,120 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in return packages + def get_diff_scan_artifacts( + self, + head_full_scan_id: str, + new_full_scan_id: str + ) -> DiffArtifacts: + """Compare two full scans via the diff-scans endpoints, polling for the result. + + Creates a diff-scan resource from the two full scan IDs, then polls + ``GET /orgs/{org}/diff-scans/{id}?cached=true`` until the API returns the + computed comparison (200) instead of a processing status (202). Unlike the + legacy ``fullscans.stream_diff`` call, no request is ever left idle while + the backend computes, so the comparison survives network idle timeouts. + See the DIFF_SCAN_POLL_* constants for the polling policy. + + Requires an org token with the ``diff-scans:create``, ``diff-scans:list`` + and ``full-scans:list`` scopes; callers are expected to catch failures and + fall back to the legacy streaming comparison. + + Note that cached diff-scan responses always embed per-package license + details (the API ignores ``omit_license_details`` when ``cached=true``), + so unlike the legacy streaming comparison there is no lean-response + option here; see the comment on ``poll_params`` below. + + Args: + head_full_scan_id: The before/base full scan ID + new_full_scan_id: The after/head full scan ID + + Returns: + DiffArtifacts with the added/removed/unchanged/replaced/updated lists + """ + create_params = { + "before": head_full_scan_id, + "after": new_full_scan_id, + "description": f"Socket Security CLI v{__version__} scan comparison", + } + try: + result = self.sdk.diffscans.create_from_ids(self.config.org_slug, create_params) + diff_scan = result.get("diff_scan") or {} + response_summary = result + except APIFailure as error: + if error.status_code != 409: + raise + + # Do not use on_duplicate=redirect here. The SDK follows that 302 + # automatically with a GET that lacks cached=true, which can leave + # the connection idle while an existing diff scan is still computing. + # Resolve the duplicate resource explicitly so every result fetch + # continues through the bounded cached polling path below. + existing = self.sdk.diffscans.list( + self.config.org_slug, + params={ + "before_full_scan_id": head_full_scan_id, + "after_full_scan_id": new_full_scan_id, + "per_page": 1, + }, + ) + matches = existing.get("results") or [] + diff_scan = matches[0] if matches else {} + response_summary = existing + + diff_scan_id = diff_scan.get("id") + if not diff_scan_id: + raise Exception( + "Error creating or resolving diff scan: " + f"unexpected response: {str(response_summary)[:500]}" + ) + artifacts_dict = diff_scan.get("artifacts") + + # cached=true is the polling contract (202 while computing, 200 when + # ready). The API ignores omit_license_details when cached=true - cached + # results always embed license details - so there is no lean-response + # option on this path (unlike stream_diff with + # include_license_details=false, the lean-payload mitigation). If that extra + # payload ever gets a response truncated on a huge dependency tree, + # response.json() fails and the caller falls back to the legacy + # streaming comparison, which still requests the lean payload. + poll_params = {"cached": "true"} + deadline = time.monotonic() + DIFF_SCAN_POLL_TIMEOUT_SECONDS + interval = DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS + while artifacts_dict is None: + try: + response = self.sdk.diffscans.get(self.config.org_slug, diff_scan_id, params=poll_params) + except APIFailure as error: + if not error.is_transient_error(): + raise + # A dropped/timed-out poll is retryable: the diff scan keeps + # computing server-side regardless of what happens to any one poll. + log.warning( + f"Transient error polling diff scan {diff_scan_id} " + f"({type(error).__name__}), retrying in {interval:.0f}s" + ) + response = {"status": "processing"} + if response.get("status") != "processing": + scan = response.get("diff_scan") or {} + if scan.get("artifacts") is None: + raise Exception( + f"Error fetching diff scan {diff_scan_id}: unexpected response: {str(response)[:500]}" + ) + artifacts_dict = scan["artifacts"] + break + if time.monotonic() >= deadline: + raise Exception( + f"Timed out waiting for diff scan {diff_scan_id} after " + f"{DIFF_SCAN_POLL_TIMEOUT_SECONDS:.0f} seconds" + ) + log.debug(f"Diff scan {diff_scan_id} still processing, polling again in {interval:.0f}s") + time.sleep(interval) + interval = min(interval * DIFF_SCAN_POLL_BACKOFF_MULTIPLIER, DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS) + + return DiffArtifacts.from_dict({ + key: artifacts_dict.get(key) or [] + for key in ("added", "removed", "unchanged", "replaced", "updated") + }) + def get_added_and_removed_packages( self, head_full_scan_id: str, @@ -1315,8 +1452,12 @@ def get_added_and_removed_packages( Args: head_full_scan_id: Previous scan (maybe None if first scan) new_full_scan_id: New scan just created - include_license_details: Whether to ask the diff endpoint to embed - per-package license attribution/details in the response. + include_license_details: Whether to ask the *legacy streaming* diff + endpoint to embed per-package license attribution/details in the + response. Only consulted on the fallback path: the primary + diff-scans path always receives embedded license details, since + the API ignores ``omit_license_details`` for cached reads (see + get_diff_scan_artifacts). Defaults to ``False`` on purpose. The diff endpoint exists to compare alerts between two scans; the license fields it can embed @@ -1343,39 +1484,55 @@ def get_added_and_removed_packages( log.info(f"Comparing scans - Head scan ID: {head_full_scan_id}, New scan ID: {new_full_scan_id}") diff_start = time.time() + diff_artifacts = None try: - diff_report = ( - self.sdk.fullscans.stream_diff( - self.config.org_slug, - head_full_scan_id, - new_full_scan_id, - use_types=True, - include_license_details=str(include_license_details).lower() - ).data + diff_artifacts = self.get_diff_scan_artifacts( + head_full_scan_id, + new_full_scan_id ) - except APIFailure as e: - log.error(f"API Error: {e}") - if self.cli_config and self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(1) - except Exception as e: - import traceback - log.error(f"Error getting diff report: {str(e)}") - log.error(f"Stack trace:\n{traceback.format_exc()}") - raise + except Exception as error: + # SDK error messages can span many lines (path + response headers); the + # first line carries the status, which is all the warning needs. + error_summary = str(error).strip().splitlines()[0] if str(error).strip() else "" + log.warning( + f"Diff scan comparison failed with {type(error).__name__}({error_summary}), " + "falling back to the streaming scan comparison" + ) + + if diff_artifacts is None: + try: + diff_artifacts = ( + self.sdk.fullscans.stream_diff( + self.config.org_slug, + head_full_scan_id, + new_full_scan_id, + use_types=True, + include_license_details=str(include_license_details).lower() + ).data.artifacts + ) + except APIFailure as e: + log.error(f"API Error: {e}") + if self.cli_config and self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(1) + except Exception as e: + import traceback + log.error(f"Error getting diff report: {str(e)}") + log.error(f"Stack trace:\n{traceback.format_exc()}") + raise diff_end = time.time() log.info(f"Diff Report Gathered in {diff_end - diff_start:.2f} seconds") log.info("Diff report artifact counts:") - log.info(f"Added: {len(diff_report.artifacts.added)}") - log.info(f"Removed: {len(diff_report.artifacts.removed)}") - log.info(f"Unchanged: {len(diff_report.artifacts.unchanged)}") - log.info(f"Replaced: {len(diff_report.artifacts.replaced)}") - log.info(f"Updated: {len(diff_report.artifacts.updated)}") - - added_artifacts = diff_report.artifacts.added + diff_report.artifacts.updated - removed_artifacts = diff_report.artifacts.removed + diff_report.artifacts.replaced - unchanged_artifacts = diff_report.artifacts.unchanged + log.info(f"Added: {len(diff_artifacts.added)}") + log.info(f"Removed: {len(diff_artifacts.removed)}") + log.info(f"Unchanged: {len(diff_artifacts.unchanged)}") + log.info(f"Replaced: {len(diff_artifacts.replaced)}") + log.info(f"Updated: {len(diff_artifacts.updated)}") + + added_artifacts = diff_artifacts.added + diff_artifacts.updated + removed_artifacts = diff_artifacts.removed + diff_artifacts.replaced + unchanged_artifacts = diff_artifacts.unchanged added_packages: Dict[str, Package] = {} removed_packages: Dict[str, Package] = {} diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 34d7909..dc9ac74 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -18,7 +18,7 @@ # Pinned @coana-tech/cli version. Bumped deliberately per Python CLI release so the # reachability engine version only changes through a standard pip upgrade (advance notice). # Pass --reach-version latest to opt into the newest published version instead. -DEFAULT_COANA_CLI_VERSION: Final = "15.10.3" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.16" # Resolved @coana-tech/cli script paths from the npm-install fallback, keyed by version. # Lives for the process lifetime so repeated fallback invocations install only once @@ -55,7 +55,7 @@ def __init__(self, sdk: socketdev, api_token: str): def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: """ - Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.3'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.16'). Args: version: Coana CLI version to use. @@ -64,7 +64,7 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str: - '': that exact version. Returns: - str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.3'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.16'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 381c2c3..ae6b10c 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -87,6 +87,22 @@ def stream_diff_response(data_dir, load_json): }) +@pytest.fixture +def diff_scan_get_response(data_dir, load_json): + """GET /orgs/{org}/diff-scans/{id} response built from the stream_diff fixture. + + The diff-scans endpoint returns the same artifact shape as the legacy + streaming diff, wrapped in a diff_scan object. + """ + json_data = load_json(data_dir / "fullscans" / "diff" / "stream_diff.json") + return { + "diff_scan": { + "id": "diff-scan-123", + "artifacts": json_data["data"]["artifacts"], + } + } + + @@ -138,6 +154,7 @@ def mock_sdk_with_responses( new_scan_metadata, new_scan_stream, stream_diff_response, + diff_scan_get_response, create_full_scan_response, ): sdk = mock_socket_sdk.return_value @@ -173,4 +190,8 @@ def mock_sdk_with_responses( lambda org_slug, head_id, new_id, **kwargs: stream_diff_response ) + # Diff-scans endpoints (primary scan-comparison path) + sdk.diffscans.create_from_ids.return_value = {"diff_scan": {"id": "diff-scan-123"}} + sdk.diffscans.get.return_value = diff_scan_get_response + return sdk diff --git a/tests/core/test_diff_scan_polling.py b/tests/core/test_diff_scan_polling.py new file mode 100644 index 0000000..be36942 --- /dev/null +++ b/tests/core/test_diff_scan_polling.py @@ -0,0 +1,122 @@ +"""Tests for the diff-scans polling scan comparison. + +The comparison must never hold an idle connection open: it creates a diff-scan +resource and polls the cached endpoint (202 while processing, 200 when ready), +falling back to the legacy streaming diff if the new flow is unavailable. +""" +import pytest +from socketdev.exceptions import APIConnectionError, APIFailure + +import socketsecurity.core as core_module +from socketsecurity.core import Core +from socketsecurity.core.socket_config import SocketConfig + + +@pytest.fixture +def core(mock_sdk_with_responses): + config = SocketConfig(api_key="test_key") + return Core(config=config, sdk=mock_sdk_with_responses) + + +@pytest.fixture +def no_sleep(mocker): + return mocker.patch("socketsecurity.core.time.sleep") + + +def test_polls_until_diff_scan_ready(core, diff_scan_get_response, no_sleep): + """202 processing responses are polled through until the 200 result arrives.""" + processing = {"status": "processing", "id": "diff-scan-123"} + core.sdk.diffscans.get.side_effect = [processing, processing, diff_scan_get_response] + + artifacts = core.get_diff_scan_artifacts("head", "new") + + assert core.sdk.diffscans.get.call_count == 3 + assert no_sleep.call_count == 2 # slept between polls, never during them + assert len(artifacts.added) > 0 + + +def test_poll_interval_backs_off(core, diff_scan_get_response, no_sleep, monkeypatch): + """The poll interval grows toward the max so long comparisons stay quota-friendly.""" + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_INITIAL_INTERVAL_SECONDS", 4.0) + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_MAX_INTERVAL_SECONDS", 10.0) + processing = {"status": "processing", "id": "diff-scan-123"} + core.sdk.diffscans.get.side_effect = [processing] * 4 + [diff_scan_get_response] + + core.get_diff_scan_artifacts("head", "new") + + waits = [call.args[0] for call in no_sleep.call_args_list] + assert waits == [4.0, 6.0, 9.0, 10.0] # 1.5x backoff, capped at the max + + +def test_transient_poll_error_is_retried(core, diff_scan_get_response, no_sleep): + """A dropped poll doesn't abandon the flow - the diff keeps computing server-side.""" + core.sdk.diffscans.get.side_effect = [APIConnectionError("reset"), diff_scan_get_response] + + artifacts = core.get_diff_scan_artifacts("head", "new") + + assert core.sdk.diffscans.get.call_count == 2 + assert len(artifacts.added) > 0 + + +def test_non_transient_poll_error_raises(core, no_sleep): + """Deterministic API errors (e.g. 403 missing scopes) propagate to the caller.""" + core.sdk.diffscans.get.side_effect = APIFailure("forbidden", status_code=403) + + with pytest.raises(APIFailure): + core.get_diff_scan_artifacts("head", "new") + + +def test_poll_timeout_raises(core, no_sleep, monkeypatch): + """A diff scan that never completes hits the polling backstop.""" + monkeypatch.setattr(core_module, "DIFF_SCAN_POLL_TIMEOUT_SECONDS", 0.0) + core.sdk.diffscans.get.return_value = {"status": "processing", "id": "diff-scan-123"} + + with pytest.raises(Exception, match="Timed out waiting for diff scan"): + core.get_diff_scan_artifacts("head", "new") + + +def test_duplicate_conflict_uses_cached_polling(core, diff_scan_get_response): + """A duplicate is resolved explicitly so the SDK cannot follow an uncached redirect.""" + core.sdk.diffscans.create_from_ids.side_effect = APIFailure( + "duplicate", status_code=409 + ) + core.sdk.diffscans.list.return_value = { + "results": [{"id": "existing-diff-scan"}], + } + + artifacts = core.get_diff_scan_artifacts("head", "new") + + create_params = core.sdk.diffscans.create_from_ids.call_args.args[1] + assert "on_duplicate" not in create_params + core.sdk.diffscans.list.assert_called_once_with( + core.config.org_slug, + params={ + "before_full_scan_id": "head", + "after_full_scan_id": "new", + "per_page": 1, + }, + ) + core.sdk.diffscans.get.assert_called_once_with( + core.config.org_slug, + "existing-diff-scan", + params={"cached": "true"}, + ) + assert len(artifacts.added) > 0 + + +def test_fallback_to_streaming_diff_on_failure(core): + """If the diff-scans flow fails (e.g. token missing the diff-scans scopes), + the comparison falls back to the legacy streaming diff transparently.""" + core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403) + + added, removed, all_packages = core.get_added_and_removed_packages("head", "new") + + core.sdk.fullscans.stream_diff.assert_called_once_with( + core.config.org_slug, + "head", + "new", + use_types=True, + include_license_details="false", + ) + assert "dp3" in added + assert "dp2" in removed diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index 9b1ce44..da0efc6 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,5 +1,6 @@ import pytest -from socketdev.fullscans import FullScanParams +from socketdev.exceptions import APIFailure +from socketdev.fullscans import FullScanParams, FullScanStreamResponse from socketsecurity.config import CliConfig from socketsecurity.core import Core @@ -225,19 +226,27 @@ def test_get_added_and_removed_packages(core): """Test getting added and removed packages between two scans""" # Get two different scans to compare added, removed, all_packages = core.get_added_and_removed_packages("head", "new") - - # Verify SDK was called correctly. - # include_license_details defaults to "false": the diff path never consumes - # embedded license data (license artifacts come from the PURL endpoint), so - # requesting it only bloats the response and risks the truncation - # crash on large repos. - core.sdk.fullscans.stream_diff.assert_called_once_with( + + # Verify SDK was called correctly: the comparison goes through the diff-scans + # endpoints (create + poll) rather than the legacy streaming diff, so no + # connection is left idle while the backend computes. + create_args = core.sdk.diffscans.create_from_ids.call_args + assert create_args[0][0] == core.config.org_slug + create_params = create_args[0][1] + assert create_params["before"] == "head" + assert create_params["after"] == "new" + assert "on_duplicate" not in create_params + + # cached=true is the polling contract (202 while computing, 200 when ready). + # No omit_license_details param: the API ignores it for cached reads (cached + # results always embed license details), so sending it would only suggest a + # leanness guarantee this path doesn't have. + core.sdk.diffscans.get.assert_called_once_with( core.config.org_slug, - "head", - "new", - use_types=True, - include_license_details="false", + "diff-scan-123", + params={"cached": "true"}, ) + core.sdk.fullscans.stream_diff.assert_not_called() # Verify the results # Added packages @@ -252,7 +261,13 @@ def test_get_added_and_removed_packages(core): assert "pypi/direct_package_1@1.6.0" in all_packages # Unchanged package is in full package map def test_get_added_and_removed_packages_license_override(core): - """The include_license_details override seam still works when explicitly requested.""" + """include_license_details only governs the legacy fallback path now: the + diff-scans path always receives embedded license details (the API ignores + omit_license_details for cached reads), so the seam must survive through to + the stream_diff call when the primary path is unavailable.""" + from socketdev.exceptions import APIFailure + + core.sdk.diffscans.create_from_ids.side_effect = APIFailure("forbidden", status_code=403) core.get_added_and_removed_packages("head", "new", include_license_details=True) core.sdk.fullscans.stream_diff.assert_called_once_with( @@ -263,6 +278,23 @@ def test_get_added_and_removed_packages_license_override(core): include_license_details="true", ) +def test_get_sbom_data_failure_raises(core): + """A failed SBOM stream fetch raises instead of returning {}. + + Returning {} let report generation continue and emit empty results with + exit code 0; raising routes the failure through the CLI's API-error + handling instead. + """ + core.sdk.fullscans.stream.side_effect = None + core.sdk.fullscans.stream.return_value = FullScanStreamResponse.from_dict({ + "success": False, + "status": 200, + "message": "Error parsing stream response", + }) + + with pytest.raises(APIFailure, match="Failed to get SBOM data"): + core.get_sbom_data("head") + def test_empty_alerts_preserved(core): """Test that empty alerts arrays stay as empty arrays and don't become None""" # Get the scan that contains dp2 (which has empty alerts array) diff --git a/uv.lock b/uv.lock index fb5a540..156b993 100644 --- a/uv.lock +++ b/uv.lock @@ -112,15 +112,15 @@ wheels = [ [[package]] name = "beautifulsoup4" -version = "4.14.3" +version = "4.15.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "soupsieve" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c3/b0/1c6a16426d389813b48d95e26898aff79abbde42ad353958ad95cc8c9b21/beautifulsoup4-4.14.3.tar.gz", hash = "sha256:6292b1c5186d356bba669ef9f7f051757099565ad9ada5dd630bd9de5fa7fb86", size = 627737, upload-time = "2025-11-30T15:08:26.084Z" } +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/39/47f9197bdd44df24d67ac8893641e16f386c984a0619ef2ee4c51fbbc019/beautifulsoup4-4.14.3-py3-none-any.whl", hash = "sha256:0918bfe44902e6ad8d57732ba310582e98da931428d231a5ecb9e7c703a735bb", size = 107721, upload-time = "2025-11-30T15:08:24.087Z" }, + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, ] [[package]] @@ -574,14 +574,14 @@ wheels = [ [[package]] name = "gitpython" -version = "3.1.57" +version = "3.1.59" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "gitdb" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ba/0d/132ed135c871b6bf91adf16a0e43797cd535b81d4973b5d09291c54fc5ee/gitpython-3.1.57.tar.gz", hash = "sha256:c493ec57c0ef6b19743798b6a5af859c71814b524e7e6f97baa2f8e658961488", size = 225898, upload-time = "2026-07-26T07:33:26.351Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ca/dc/126b28e76b24a9268ba931ad3e012f71ebdadf62fd9f17758f7074bb0b20/gitpython-3.1.59.tar.gz", hash = "sha256:0a1475cfdc38a5bfba1a3e9a4a9da52a39749ecec322b772915c019f94e5b7e4", size = 230445, upload-time = "2026-08-10T12:03:20.271Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/41/6e/2139de986d9c7c3ac86f1f8be43858ce90bdfe2f7175e6c80c650ba15242/gitpython-3.1.57-py3-none-any.whl", hash = "sha256:4ccf7d73c10f5c9e76043fbb2675ac5a1b3ff5b41e648f56bcbed5f63792ecaf", size = 217151, upload-time = "2026-07-26T07:33:24.838Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ed/ae57eb7d344f43f87b74b3a281ead6ec7d6394eef72a7b1dcb28dd089550/gitpython-3.1.59-py3-none-any.whl", hash = "sha256:67a82f537384578643624c8b2c531938a9b82be431663e575dcf638526631d4c", size = 220996, upload-time = "2026-08-10T12:03:18.804Z" }, ] [[package]] @@ -795,11 +795,11 @@ wheels = [ [[package]] name = "markdown" -version = "3.10.2" +version = "3.10.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/f4/69fa6ed85ae003c2378ffa8f6d2e3234662abd02c10d216c0ba96081a238/markdown-3.10.2.tar.gz", hash = "sha256:994d51325d25ad8aa7ce4ebaec003febcce822c3f8c911e3b17c52f7f589f950", size = 368805, upload-time = "2026-02-09T14:57:26.942Z" } +sdist = { url = "https://files.pythonhosted.org/packages/29/6f/da4c6aea59b3001f2e8c0ec7497475aadaf3b021c10cab5b2858f0f32b26/markdown-3.10.3.tar.gz", hash = "sha256:3589362618f743188b4d955b874402bc814f4f83f544dc207719f4baa7d9c45f", size = 372596, upload-time = "2026-07-30T19:05:29.005Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/1f/77fa3081e4f66ca3576c896ae5d31c3002ac6607f9747d2e3aa49227e464/markdown-3.10.2-py3-none-any.whl", hash = "sha256:e91464b71ae3ee7afd3017d9f358ef0baf158fd9a298db92f1d4761133824c36", size = 108180, upload-time = "2026-02-09T14:57:25.787Z" }, + { url = "https://files.pythonhosted.org/packages/64/69/4a5af2bc115a9a33fefe51709749de8262be3f9ba063d1753a837cdbc49c/markdown-3.10.3-py3-none-any.whl", hash = "sha256:fa6c92a00a4a3c98b22728c64a935ae1928250ae65058a6ded814d2cc29a4cea", size = 110757, upload-time = "2026-07-30T19:05:27.883Z" }, ] [[package]] @@ -1200,40 +1200,40 @@ wheels = [ [[package]] name = "rich" -version = "14.2.0" +version = "15.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fb/d2/8920e102050a0de7bfabeb4c4614a49248cf8d5d7a8d01885fbb24dc767a/rich-14.2.0.tar.gz", hash = "sha256:73ff50c7c0c1c77c8243079283f4edb376f0f6442433aecb8ce7e6d0b92d1fe4", size = 219990, upload-time = "2025-10-09T14:16:53.064Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/7a/b0178788f8dc6cafce37a212c99565fa1fe7872c70c6c9c1e1a372d9d88f/rich-14.2.0-py3-none-any.whl", hash = "sha256:76bc51fe2e57d2b1be1f96c524b890b816e334ab4c1e45888799bfaab0021edd", size = 243393, upload-time = "2025-10-09T14:16:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, ] [[package]] name = "ruff" -version = "0.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4d/94/1e5e4967626faf12fa56999cd6222dff6992ceb086ad7945756baf70c7a7/ruff-0.16.0.tar.gz", hash = "sha256:e460aafd5495ec89efaa6ced2e4a9a581116451e1c88b9d37ef497e0f8e93982", size = 4790557, upload-time = "2026-07-23T19:11:30.981Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/81/1c8818fee7ce1a04cd7d1b3172e0a8f8e4f1dc4feb7fc390e16daa8af323/ruff-0.16.0-py3-none-linux_armv6l.whl", hash = "sha256:e5115729eb08c585e5121978ba5d5b60caeae394ce21b9fb5e6cd33a1c6c9b1e", size = 10754633, upload-time = "2026-07-23T19:10:46.415Z" }, - { url = "https://files.pythonhosted.org/packages/23/df/beaf59c09d68db84304d555f188b276a77132a5d5b0b67a5c762aa143628/ruff-0.16.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3c954b1d580bfa035b41654f7858cc7e71d5fc3ac5b723dd62bd9133830ed522", size = 10969164, upload-time = "2026-07-23T19:10:50.271Z" }, - { url = "https://files.pythonhosted.org/packages/42/ce/741cd197496a1abbf51352710fd15ed995d2a2be87189c1da26a450d6e83/ruff-0.16.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e01c21d10eb1b29f47b7454e1f4056db9a3f0260c646aa88457c610291db9f81", size = 10488846, upload-time = "2026-07-23T19:10:52.639Z" }, - { url = "https://files.pythonhosted.org/packages/52/2a/a2db8e88cade358f5cdcb05674a917751074109315d014eb6352d9a893f7/ruff-0.16.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e364e5ed22ed8dc05082fd78e35308618260907ac2d3c1d637b2e682415b6c9", size = 10889729, upload-time = "2026-07-23T19:10:54.89Z" }, - { url = "https://files.pythonhosted.org/packages/42/65/62a771694ebd63029dc953e27dbad40e1588bd4860ff9fe881018fddaa49/ruff-0.16.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d327b8fc113a1d4421a04f3839d3752057c8dd1ee320223a6f3f52d04ada462a", size = 10568275, upload-time = "2026-07-23T19:10:56.993Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e2/ced249fe8af5f086c5c58cc21cc3356d50f32f7401c5df87050c999620a7/ruff-0.16.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9b50c55e263103586b3dcf5f73d479eb8cb5fdb6098fec59a62891dab653717", size = 11385112, upload-time = "2026-07-23T19:10:59.615Z" }, - { url = "https://files.pythonhosted.org/packages/87/0b/05154977a8fd69eeb6c103271f55403bfd8711f5c0f8ed07489d95a504e7/ruff-0.16.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ff4a79ce3ec0172f3241943835de1c4cb4e2dcd07f0f8c2d02603dbbbee4b17", size = 12207008, upload-time = "2026-07-23T19:11:02.154Z" }, - { url = "https://files.pythonhosted.org/packages/fb/29/98225831a3a1eab0e02f4acc6ca6559a98611dcc68b6965ff4b7234627c1/ruff-0.16.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e95c448fca1fb2a18372a9440926c5a6ee789639bb975c72e7ae6d0b04218ab4", size = 11650842, upload-time = "2026-07-23T19:11:04.557Z" }, - { url = "https://files.pythonhosted.org/packages/91/66/6bd3cf90500653d55dc0ffc8507aa8300bd49d0214b2e8cb4d3fef2943ba/ruff-0.16.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f11a8d11010301d0a398a2fdef67691feca7294da6aef55e2150e8fa2cd520b", size = 11400718, upload-time = "2026-07-23T19:11:09.233Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a2/a54eb4eae05d66364050a5d3b8a9c5ef88196531b3cbe7109d873f87f819/ruff-0.16.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:48044c678e9cb8698246c99b14aaccfa6601dea7379eb48a6f8f73f7a6d86cd0", size = 11426177, upload-time = "2026-07-23T19:11:11.994Z" }, - { url = "https://files.pythonhosted.org/packages/1a/be/16e3eea4b2a478a496919f5e36f17c4559e54620bd3bbac5d6affa068006/ruff-0.16.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:7aa0959bad8eb8bef50340154fc9b58678dae31fa4293afa38b44b6e552c0213", size = 10856126, upload-time = "2026-07-23T19:11:14.221Z" }, - { url = "https://files.pythonhosted.org/packages/a2/84/252eb8b868a16eec7257c14f504f77537e734b2d69c762e639e588e304a3/ruff-0.16.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:28ea2b7df8ebf7f9da6b7d47b230ab48f387c0a29be3b474c4d0740e197bb9af", size = 10571208, upload-time = "2026-07-23T19:11:16.378Z" }, - { url = "https://files.pythonhosted.org/packages/21/09/817a482f542f7570cbb4554b26e896610c7114f539b1d9e2d2145bf6bef6/ruff-0.16.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:33a3dfac8c35f81498dea9181bccc2f4c4bc8f1521a1dd9406e77643e0f0fb09", size = 11063329, upload-time = "2026-07-23T19:11:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/2e/23/9403c180ca1cb9b1f7335f5c3e5305c09d49ea5b345196682a36028bde4a/ruff-0.16.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a5237a0bda500d30d81b8e07a6973a5cbc772864cbf746ae2f4e8a2e01c9f4ed", size = 11489751, upload-time = "2026-07-23T19:11:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/1b2ef7bcde851c78d7f17f1cca13fd6dc695fc4b3d6197941e72cae5b132/ruff-0.16.0-py3-none-win32.whl", hash = "sha256:7fab76fa065c873f41ff744347c6e77bcc3dfec4bcc754dc26b63d23c0f7f5fb", size = 10785885, upload-time = "2026-07-23T19:11:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a3/d5e4ef7a56be3f928ffb90b94c25ba7d3cb9c7fe0736aeaaedf361770712/ruff-0.16.0-py3-none-win_amd64.whl", hash = "sha256:429c117f022bf481fabd9d551e7a3952b24c65e6ef44337ea09d90bebef14472", size = 11923141, upload-time = "2026-07-23T19:11:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/cb/9a/8415f2657cbe200f41a4531ccededf135505a92d4a012229121f885b26f9/ruff-0.16.0-py3-none-win_arm64.whl", hash = "sha256:14296fedcd2705c77ab8235439278bbb38f285cf7da5528b00b3e330c3d4872d", size = 11273407, upload-time = "2026-07-23T19:11:28.705Z" }, +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, ] [[package]] @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.0" +version = "2.6.5" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, @@ -1321,27 +1321,27 @@ dev = [ [package.metadata] requires-dist = [ - { name = "beautifulsoup4", specifier = "==4.14.3" }, + { name = "beautifulsoup4", specifier = "==4.15.0" }, { name = "brotli", marker = "platform_python_implementation == 'CPython'", specifier = "==1.2.0" }, { name = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.1" }, - { name = "gitpython", specifier = "==3.1.57" }, - { name = "hatch", marker = "extra == 'dev'" }, - { name = "markdown", specifier = "==3.10.2" }, + { name = "gitpython", specifier = "==3.1.59" }, + { name = "hatch", marker = "extra == 'dev'", specifier = "==1.17.1" }, + { name = "markdown", specifier = "==3.10.3" }, { name = "mdutils", specifier = "==1.8.1" }, { name = "packaging", specifier = "==26.2" }, - { name = "pre-commit", marker = "extra == 'dev'" }, + { name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.6.1" }, { name = "prettytable", specifier = "==3.18.0" }, - { name = "pytest", marker = "extra == 'test'", specifier = ">=7.4.0" }, - { name = "pytest-asyncio", marker = "extra == 'test'", specifier = ">=0.23.0" }, - { name = "pytest-cov", marker = "extra == 'test'", specifier = ">=4.1.0" }, - { name = "pytest-mock", marker = "extra == 'test'", specifier = ">=3.12.0" }, - { name = "pytest-watch", marker = "extra == 'test'", specifier = ">=4.2.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = "==9.1.1" }, + { name = "pytest-asyncio", marker = "extra == 'test'", specifier = "==1.4.0" }, + { name = "pytest-cov", marker = "extra == 'test'", specifier = "==7.1.0" }, + { name = "pytest-mock", marker = "extra == 'test'", specifier = "==3.15.1" }, + { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.2" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.3.0" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.1" }, { name = "socketdev", specifier = "==3.5.0" }, - { name = "twine", marker = "extra == 'dev'" }, - { name = "uv", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.0" }, ] provides-extras = ["test", "dev"] @@ -1444,7 +1444,7 @@ wheels = [ [[package]] name = "twine" -version = "6.2.0" +version = "7.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "id" }, @@ -1457,9 +1457,9 @@ dependencies = [ { name = "rich" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e0/a8/949edebe3a82774c1ec34f637f5dd82d1cf22c25e963b7d63771083bbee5/twine-6.2.0.tar.gz", hash = "sha256:e5ed0d2fd70c9959770dce51c8f39c8945c574e18173a7b81802dab51b4b75cf", size = 172262, upload-time = "2025-09-04T15:43:17.255Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/3c/58f808a359700f39a967dffede33efeac809262c03303fa3eec6afff8f49/twine-7.0.0.tar.gz", hash = "sha256:85cdb29c518efef867360ae4acd4b0dfd61c8654a22fca08e6f8539f05022177", size = 215032, upload-time = "2026-07-27T15:59:00.825Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/7a/882d99539b19b1490cac5d77c67338d126e4122c8276bf640e411650c830/twine-6.2.0-py3-none-any.whl", hash = "sha256:418ebf08ccda9a8caaebe414433b0ba5e25eb5e4a927667122fbe8f829f985d8", size = 42727, upload-time = "2025-09-04T15:43:15.994Z" }, + { url = "https://files.pythonhosted.org/packages/96/08/ddcdc06225eaad6de0e48e1002b06d919dbde20582d0662c7af51308e5d6/twine-7.0.0-py3-none-any.whl", hash = "sha256:b854164df26db268af05f49aa5c0344b10e27a494343ff05b1e0bad3b135f5a7", size = 43204, upload-time = "2026-07-27T15:58:59.26Z" }, ] [[package]]