From 1eaf52869ccc16a4bd73f748e8d28e0285200fc4 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:49:56 +0200 Subject: [PATCH 01/21] Bump pinned @coana-tech/cli to 15.10.36 (#335) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21150734..1007a3b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.7.1 + +### Changed: bump pinned @coana-tech/cli to 15.10.36 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.32` to + `15.10.36`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.7.0 ### Fixed: unreadable reachability facts no longer report a blocking package diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 6c94daf3..51ad6466 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.32 | 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.36 | 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 293bbd13..3dcd5718 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.0" +version = "2.7.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index d72ecc6e..78220a1f 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.0' +__version__ = '2.7.1' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 830a5118..7ddf7c68 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.32" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.36" # 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.32'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.36'). 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.32'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.36'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 90f23669..d0338009 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.0" +version = "2.7.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 6076acf0df8457bb900dc669cfd4da5ae093a3a4 Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Thu, 3 Sep 2026 15:16:03 +0200 Subject: [PATCH 02/21] Stop passing --lazy-mode to the coana CLI (#336) The coana CLI no longer accepts --lazy-mode, so a reachability run started with --reach-lazy-mode failed on an unrecognized argument. --reach-lazy-mode is kept as a hidden no-op, matching how --reach-disable-analysis-splitting was deprecated, so pipelines that still pass it keep working rather than failing argument parsing. Nothing is forwarded to coana, and the flag is dropped from the reachability documentation. No version bump: this is not being released on its own. --- docs/cli-reference.md | 3 +-- socketsecurity/config.py | 4 ++-- socketsecurity/core/tools/reachability.py | 5 ----- socketsecurity/socketcli.py | 1 - 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 51ad6466..0d4d4833 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -156,7 +156,7 @@ socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--workspace WORKSPACE] [-- [--reach] [--reach-version REACH_VERSION] [--reach-analysis-timeout REACH_ANALYSIS_TIMEOUT] [--reach-analysis-memory-limit REACH_ANALYSIS_MEMORY_LIMIT] [--reach-concurrency REACH_CONCURRENCY] [--reach-ecosystems REACH_ECOSYSTEMS] [--reach-min-severity ] [--reach-skip-cache] [--reach-disable-analytics] [--reach-enable-analysis-splitting] [--reach-detailed-analysis-log-file] - [--reach-lazy-mode] [--reach-use-only-pregenerated-sboms] [--reach-debug] [--reach-disable-external-tool-checks] + [--reach-use-only-pregenerated-sboms] [--reach-debug] [--reach-disable-external-tool-checks] [--reach-output-file REACH_OUTPUT_FILE] [--only-facts-file] [--version] ```` @@ -286,7 +286,6 @@ If you don't want to provide the Socket API Token every time then you can use th | `--reach-disable-analytics` | False | False | Disable analytics collection during reachability analysis | | `--reach-enable-analysis-splitting` | False | False | Enable analysis splitting/bucketing (a legacy performance feature). Splitting is disabled by default. | | `--reach-detailed-analysis-log-file` | False | False | Write a detailed analysis log file; its path is printed to stdout | -| `--reach-lazy-mode` | False | False | Enable lazy mode (experimental performance feature) | | `--reach-use-only-pregenerated-sboms` | False | False | Build the scan only from pre-generated CycloneDX (CDX) and SPDX files in your project (requires --reach) | | `--reach-debug` | False | False | Enable coana debug output (`--debug`) for the analysis, independent of the global `--enable-debug` | | `--reach-disable-external-tool-checks` | False | False | Disable coana's external tool availability checks (passes `--disable-external-tool-checks`) | diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 26542447..0a6a5be4 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -176,7 +176,7 @@ class CliConfig: reach_disable_analysis_splitting: bool = False # Deprecated, kept for backwards compatibility reach_enable_analysis_splitting: bool = False reach_detailed_analysis_log_file: bool = False - reach_lazy_mode: bool = False + reach_lazy_mode: bool = False # Deprecated, kept for backwards compatibility reach_ecosystems: Optional[List[str]] = None reach_exclude_paths: Optional[List[str]] = None reach_skip_cache: bool = False @@ -1091,7 +1091,7 @@ def create_argument_parser() -> argparse.ArgumentParser: "--reach-lazy-mode", dest="reach_lazy_mode", action="store_true", - help="Enable lazy mode for reachability analysis. This is an experimental feature for improving performance" + help=argparse.SUPPRESS # Deprecated, kept for backwards compatibility (no-op) ) reachability_group.add_argument( "--reach-output-file", diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 7ddf7c68..ff409b7b 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -88,7 +88,6 @@ def run_reachability_analysis( disable_analytics: bool = False, enable_analysis_splitting: bool = False, detailed_analysis_log_file: bool = False, - lazy_mode: bool = False, repo_name: Optional[str] = None, branch_name: Optional[str] = None, version: Optional[str] = None, @@ -123,7 +122,6 @@ def run_reachability_analysis( disable_analytics: Disable analytics sharing enable_analysis_splitting: Enable analysis splitting (disabled by default) detailed_analysis_log_file: Print detailed analysis log file path - lazy_mode: Enable lazy mode for analysis repo_name: Repository name branch_name: Branch name version: @coana-tech/cli version to use. None uses the pinned @@ -172,9 +170,6 @@ def run_reachability_analysis( if detailed_analysis_log_file: coana_args.append("--print-analysis-log-file") - if lazy_mode: - coana_args.append("--lazy-mode") - # KEY POINT: Only add manifest tar hash if we have one if tar_hash: coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash]) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 0c967ccf..b4844f31 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -450,7 +450,6 @@ def main_code(): disable_analytics=config.reach_disable_analytics or False, enable_analysis_splitting=config.reach_enable_analysis_splitting or False, detailed_analysis_log_file=config.reach_detailed_analysis_log_file or False, - lazy_mode=config.reach_lazy_mode or False, repo_name=None if repo_defaulted else config.repo, branch_name=None if branch_defaulted else config.branch, version=config.reach_version, From 4601aa130c5013fe5961f2d6261dcb9c04dfa485 Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Fri, 4 Sep 2026 10:10:39 +0200 Subject: [PATCH 03/21] Make tar_hash required for reachability analysis (#341) run_reachability_analysis() always receives a manifest tar hash from its only caller, so drop the Optional default and the conditional that skipped --run-without-docker/--manifests-tar-hash when it was absent. Pass tar_hash at the test call sites accordingly. --- socketsecurity/core/tools/reachability.py | 10 +++------- socketsecurity/socketcli.py | 14 ++++++-------- tests/unit/test_reachability.py | 16 ++++++++-------- 3 files changed, 17 insertions(+), 23 deletions(-) diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index ff409b7b..efa5f9c5 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -77,7 +77,7 @@ def run_reachability_analysis( self, org_slug: str, target_directory: str, - tar_hash: Optional[str] = None, + tar_hash: str, output_path: str = ".socket.facts.json", timeout: Optional[str] = None, memory_limit: Optional[str] = None, @@ -151,9 +151,7 @@ def run_reachability_analysis( # Add conditional arguments. timeout/memory_limit are forwarded verbatim; coana owns # unit parsing/validation (e.g. '90s', '8GB'). We coerce to str only for subprocess - # safety โ€” config-file values can arrive as ints via argparse set_defaults โ€” and use - # `is not None` (not truthiness) so an explicit empty string still reaches coana and - # triggers coana's own error, rather than being silently dropped. + # safety โ€” config-file values can arrive as ints via argparse set_defaults. if timeout is not None: coana_args.extend(["--analysis-timeout", str(timeout)]) @@ -170,9 +168,7 @@ def run_reachability_analysis( if detailed_analysis_log_file: coana_args.append("--print-analysis-log-file") - # KEY POINT: Only add manifest tar hash if we have one - if tar_hash: - coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash]) + coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash]) if ecosystems: coana_args.extend(["--purl-types"] + ecosystems) diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index b4844f31..1179e915 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -391,14 +391,11 @@ def main_code(): # Find manifest files in scan paths (excluding .socket.facts.json to avoid circular dependency) log.info("Finding manifest files for reachability analysis...") - manifest_files = [] - - # Always find all manifest files for the tar hash upload - for scan_path in scan_paths: - scan_manifests = core.find_files(scan_path) - # Filter out .socket.facts.json files from manifest upload - scan_manifests = [f for f in scan_manifests if not f.endswith('.socket.facts.json')] - manifest_files.extend(scan_manifests) + manifest_files = [ + # Always find all manifest files for the tar hash upload + f for scan_path in scan_paths for f in core.find_files(scan_path) + if not f.endswith('.socket.facts.json') + ] if not manifest_files: log.warning("No manifest files found for reachability analysis") @@ -410,6 +407,7 @@ def main_code(): try: # Get org_slug early (we'll need it) org_slug = core.config.org_slug + assert org_slug # Upload manifest files tar_hash = sdk.uploadmanifests.upload_manifest_files( diff --git a/tests/unit/test_reachability.py b/tests/unit/test_reachability.py index 670fd57f..71a20dc0 100644 --- a/tests/unit/test_reachability.py +++ b/tests/unit/test_reachability.py @@ -44,7 +44,7 @@ def _spawn_mock(analyzer, mocker, returncode=0, **kwargs): completed.returncode = returncode run_mock = mocker.patch.object(reachability.subprocess, "run", return_value=completed) - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", **kwargs) + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123", **kwargs) return run_mock @@ -246,7 +246,7 @@ def fake_run(argv, **_kw): return m mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", **kwargs) + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123", **kwargs) return calls @@ -285,7 +285,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") assert calls[0][0] == "npx" assert all(c[:2] != ["npm", "install"] for c in calls) @@ -313,7 +313,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") assert all(c[:2] != ["npm", "install"] for c in calls) @@ -340,7 +340,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") assert calls[0][0] == "npx" assert all(c[:2] != ["npm", "install"] for c in calls) @@ -385,8 +385,8 @@ def fake_run(argv, **_kw): return m mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") npm_installs = [c for c in calls if c[:2] == ["npm", "install"]] assert len(npm_installs) == 1 # installed once, reused on the second fallback @@ -415,7 +415,7 @@ def fake_run(argv, **_kw): mocker.patch.object(reachability.subprocess, "run", side_effect=fake_run) with pytest.raises(Exception, match="node"): - analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".") + analyzer.run_reachability_analysis(org_slug="my-org", target_directory=".", tar_hash="tar-hash-abc123") def test_build_coana_node_cmd_js_vs_binary(): From eb3e46a3c00d0ebcb081c90ff5683d869fc832b5 Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Fri, 4 Sep 2026 13:58:35 +0200 Subject: [PATCH 04/21] Apply ruff autofixes and add a ruff CI job (#342) * Apply ruff autofixes for imports and lint nits Result of `uv run ruff check --fix --unsafe-fixes`: sort and regroup imports (isort), drop unused imports, remove f-string prefixes from strings with no placeholders, drop unused bindings, and simplify `== True`/`== False` assertions to truthiness checks. * Add a ruff job to the unit test workflow Mirrors the checkout and python setup from python-tests. Installs the dev extra rather than test, since that is where the ruff pin lives. --- .github/workflows/python-tests.yml | 20 +++++++++++++++++++ socketsecurity/config.py | 8 +++++--- socketsecurity/core/cli_client.py | 1 + socketsecurity/core/helper/__init__.py | 3 ++- .../core/helper/socket_facts_loader.py | 6 +++--- socketsecurity/core/lazy_file_loader.py | 4 +--- socketsecurity/core/messages.py | 1 + socketsecurity/core/resource_utils.py | 1 - socketsecurity/core/scm/client.py | 1 + socketsecurity/core/scm/gitlab.py | 7 ++++--- socketsecurity/core/socket_config.py | 7 +++---- socketsecurity/core/tools/reachability.py | 13 ++++++------ socketsecurity/output.py | 15 ++++++++------ socketsecurity/plugins/formatters/slack.py | 2 +- socketsecurity/plugins/jira.py | 9 ++++++--- socketsecurity/plugins/manager.py | 2 +- socketsecurity/plugins/slack.py | 13 +++++++----- socketsecurity/plugins/teams.py | 4 +++- socketsecurity/plugins/webhook.py | 4 +++- tests/core/test_diff_alerts.py | 1 - tests/unit/test_cli_config.py | 1 + tests/unit/test_client.py | 12 ++++++----- tests/unit/test_config.py | 8 +++++--- tests/unit/test_disable_ignore.py | 2 -- tests/unit/test_gitlab_auth.py | 3 ++- tests/unit/test_gitlab_auth_fallback.py | 11 +++++----- tests/unit/test_gitlab_commit_status.py | 4 +--- tests/unit/test_gitlab_format.py | 3 +-- tests/unit/test_include_dirs.py | 2 -- tests/unit/test_socketcli.py | 8 +++++--- 30 files changed, 107 insertions(+), 69 deletions(-) diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index 34717226..f05b27c9 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -67,6 +67,26 @@ jobs: uv export --no-hashes --no-emit-project --format requirements-txt > /tmp/req-audit.txt uvx pip-audit --strict --progress-spinner off --disable-pip --no-deps -r /tmp/req-audit.txt + ruff: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + - name: ๐Ÿ setup python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: ${{ env.PYTHON_VERSION }} + - name: ๐Ÿ› ๏ธ install deps + run: | + python -m pip install --upgrade pip + pip install uv + uv sync --extra dev + - name: ๐Ÿงน run ruff + run: uv run ruff check + unsupported-python-install: runs-on: ubuntu-latest timeout-minutes: 10 diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 0a6a5be4..35904976 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -1,12 +1,14 @@ import argparse +import json import logging import os +import tomllib from dataclasses import asdict, dataclass, field from typing import List, Optional -from socketsecurity import __version__ + from socketdev import INTEGRATION_TYPES, IntegrationType -import json -import tomllib + +from socketsecurity import __version__ def get_plugin_config_from_env(prefix: str) -> dict: diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index bfad0d14..2e941e7a 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -6,6 +6,7 @@ import requests from socketsecurity import USER_AGENT + from .exceptions import APIFailure from .socket_config import SocketConfig diff --git a/socketsecurity/core/helper/__init__.py b/socketsecurity/core/helper/__init__.py index ab7d06f7..224f3cc7 100644 --- a/socketsecurity/core/helper/__init__.py +++ b/socketsecurity/core/helper/__init__.py @@ -1,7 +1,8 @@ +import string + import markdown from bs4 import BeautifulSoup, Tag from bs4.element import NavigableString -import string class Helper: diff --git a/socketsecurity/core/helper/socket_facts_loader.py b/socketsecurity/core/helper/socket_facts_loader.py index fd93b9dd..26c1ae25 100644 --- a/socketsecurity/core/helper/socket_facts_loader.py +++ b/socketsecurity/core/helper/socket_facts_loader.py @@ -2,9 +2,9 @@ import json import logging -from pathlib import Path -from typing import Dict, Any, Optional, List from copy import deepcopy +from pathlib import Path +from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) @@ -65,7 +65,7 @@ def load_socket_facts(file_path: str = ".socket.facts.json") -> Optional[Dict[st return None if 'components' not in data: - logger.warning(f"Socket facts file missing 'components' key") + logger.warning("Socket facts file missing 'components' key") return data diff --git a/socketsecurity/core/lazy_file_loader.py b/socketsecurity/core/lazy_file_loader.py index 9127652b..a5bfa15a 100644 --- a/socketsecurity/core/lazy_file_loader.py +++ b/socketsecurity/core/lazy_file_loader.py @@ -2,9 +2,7 @@ Lazy file loading utilities for efficient manifest file processing. """ import logging -from typing import List, Tuple, Union, BinaryIO -from io import BytesIO -import os +from typing import List, Tuple log = logging.getLogger("socketdev") diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 673dde5c..d968c14b 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -5,6 +5,7 @@ import uuid from datetime import datetime, timezone from pathlib import Path + from mdutils import MdUtils from prettytable import PrettyTable diff --git a/socketsecurity/core/resource_utils.py b/socketsecurity/core/resource_utils.py index b49cc2e9..dc78c1b8 100644 --- a/socketsecurity/core/resource_utils.py +++ b/socketsecurity/core/resource_utils.py @@ -2,7 +2,6 @@ System resource utilities for the Socket Security CLI. """ import logging -import sys # The resource module is only available on Unix-like systems resource_available = False diff --git a/socketsecurity/core/scm/client.py b/socketsecurity/core/scm/client.py index 05757117..08769f3f 100644 --- a/socketsecurity/core/scm/client.py +++ b/socketsecurity/core/scm/client.py @@ -2,6 +2,7 @@ from typing import Dict from socketsecurity import USER_AGENT + from ..cli_client import CliClient diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index b3b3492f..2c3947de 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -2,9 +2,10 @@ import os import sys from dataclasses import dataclass -from typing import Optional, Union +from typing import Optional import requests + from socketsecurity import USER_AGENT from socketsecurity.core import log from socketsecurity.core.classes import Comment @@ -140,7 +141,7 @@ def _request_with_fallback(self, **kwargs): except requests.exceptions.HTTPError as e: # Check if this is an authentication error (401) if e.response and e.response.status_code == 401: - log.debug(f"Authentication failed with initial headers, trying fallback method") + log.debug("Authentication failed with initial headers, trying fallback method") # Determine the fallback headers original_headers = kwargs.get('headers', self.config.headers) @@ -153,7 +154,7 @@ def _request_with_fallback(self, **kwargs): # Re-raise the original exception if it's not an auth error or fallback failed raise - except Exception as e: + except Exception: # Handle other types of exceptions that don't have response attribute raise diff --git a/socketsecurity/core/socket_config.py b/socketsecurity/core/socket_config.py index acf01a2d..1089a530 100644 --- a/socketsecurity/core/socket_config.py +++ b/socketsecurity/core/socket_config.py @@ -1,12 +1,11 @@ +import os from dataclasses import dataclass, field -from typing import Dict, Optional +from typing import List, Optional, Set from urllib.parse import urlparse -from typing import Set, List -import os from socketdev.core.issues import AllIssues -from socketsecurity import __version__ +from socketsecurity import __version__ default_exclude_dirs = { "node_modules", "bower_components", "jspm_packages", # JS/TS diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index efa5f9c5..16ddfb1e 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -1,15 +1,16 @@ -from socketdev import socketdev -from typing import List, Optional, Dict, Any, Final import atexit +import json +import logging import os +import pathlib import platform import shutil import subprocess -import json -import pathlib -import logging import sys import tempfile +from typing import Any, Dict, Final, List, Optional + +from socketdev import socketdev from socketsecurity import __version__ @@ -258,7 +259,7 @@ def run_reachability_analysis( # Extract scan ID from output file scan_id = self._extract_scan_id(output_path) - log.info(f"Reachability analysis completed successfully") + log.info("Reachability analysis completed successfully") if scan_id: log.info(f"Scan ID: {scan_id}") diff --git a/socketsecurity/output.py b/socketsecurity/output.py index 63fe565b..769bfd33 100644 --- a/socketsecurity/output.py +++ b/socketsecurity/output.py @@ -2,18 +2,21 @@ import logging from pathlib import Path from typing import Any, Dict, Optional -from .core.messages import Messages -from .core.classes import Diff, Issue -from .config import CliConfig -from .fossa_compat import build_fossa_report_payload -from socketsecurity.plugins.manager import PluginManager + +from socketdev import socketdev + from socketsecurity.core.alert_selection import ( clone_diff_with_selected_alerts, filter_alerts_by_reachability, load_components_with_alerts, select_diff_alerts, ) -from socketdev import socketdev +from socketsecurity.plugins.manager import PluginManager + +from .config import CliConfig +from .core.classes import Diff, Issue +from .core.messages import Messages +from .fossa_compat import build_fossa_report_payload class OutputHandler: diff --git a/socketsecurity/plugins/formatters/slack.py b/socketsecurity/plugins/formatters/slack.py index adce7eec..1a3ab874 100644 --- a/socketsecurity/plugins/formatters/slack.py +++ b/socketsecurity/plugins/formatters/slack.py @@ -1,8 +1,8 @@ """Slack formatter for Socket Facts (reachability analysis) data.""" import logging -from typing import Dict, Any, List from collections import defaultdict +from typing import Any, Dict, List logger = logging.getLogger(__name__) diff --git a/socketsecurity/plugins/jira.py b/socketsecurity/plugins/jira.py index 7dc6fe0b..9de187f9 100644 --- a/socketsecurity/plugins/jira.py +++ b/socketsecurity/plugins/jira.py @@ -1,9 +1,12 @@ -from .base import Plugin -import requests import base64 -from socketsecurity.core.classes import Diff + +import requests + from socketsecurity.config import CliConfig from socketsecurity.core import log +from socketsecurity.core.classes import Diff + +from .base import Plugin class JiraPlugin(Plugin): diff --git a/socketsecurity/plugins/manager.py b/socketsecurity/plugins/manager.py index b2397d11..9929fa79 100644 --- a/socketsecurity/plugins/manager.py +++ b/socketsecurity/plugins/manager.py @@ -1,4 +1,4 @@ -from . import jira, webhook, slack, teams +from . import jira, slack, teams, webhook PLUGIN_CLASSES = { "jira": jira.JiraPlugin, diff --git a/socketsecurity/plugins/slack.py b/socketsecurity/plugins/slack.py index d5b4b62b..fae3b2c7 100644 --- a/socketsecurity/plugins/slack.py +++ b/socketsecurity/plugins/slack.py @@ -1,22 +1,25 @@ import logging import os + import requests + from socketsecurity.config import CliConfig -from .base import Plugin -from socketsecurity.core.classes import Diff -from socketsecurity.core.messages import Messages from socketsecurity.core.alert_selection import ( clone_diff_with_selected_alerts, filter_alerts_by_reachability, select_diff_alerts, ) +from socketsecurity.core.classes import Diff from socketsecurity.core.helper.socket_facts_loader import ( - load_socket_facts, + convert_to_alerts, get_components_with_vulnerabilities, - convert_to_alerts + load_socket_facts, ) +from socketsecurity.core.messages import Messages from socketsecurity.plugins.formatters.slack import format_socket_facts_for_slack +from .base import Plugin + logger = logging.getLogger(__name__) diff --git a/socketsecurity/plugins/teams.py b/socketsecurity/plugins/teams.py index def9522d..9f347094 100644 --- a/socketsecurity/plugins/teams.py +++ b/socketsecurity/plugins/teams.py @@ -1,6 +1,8 @@ -from .base import Plugin import requests +from .base import Plugin + + class TeamsPlugin(Plugin): def send(self, message, level): if not self.config.get("enabled", False): diff --git a/socketsecurity/plugins/webhook.py b/socketsecurity/plugins/webhook.py index 4793c67f..17857529 100644 --- a/socketsecurity/plugins/webhook.py +++ b/socketsecurity/plugins/webhook.py @@ -1,6 +1,8 @@ -from .base import Plugin import requests +from .base import Plugin + + class WebhookPlugin(Plugin): def send(self, message, level): if not self.config.get("enabled", False): diff --git a/tests/core/test_diff_alerts.py b/tests/core/test_diff_alerts.py index 5455e3c0..9b5be92d 100644 --- a/tests/core/test_diff_alerts.py +++ b/tests/core/test_diff_alerts.py @@ -1,4 +1,3 @@ -import pytest from socketsecurity.core import Core from socketsecurity.core.classes import Issue diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 39447c2c..6aa9bf1a 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1,4 +1,5 @@ import pytest + from socketsecurity.config import CliConfig diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index e0f62d85..045c66d4 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -1,9 +1,12 @@ -import pytest from unittest.mock import Mock, patch + +import pytest import requests + from socketsecurity.core.cli_client import CliClient -from socketsecurity.core.socket_config import SocketConfig from socketsecurity.core.exceptions import APIFailure +from socketsecurity.core.socket_config import SocketConfig + @pytest.fixture def config(): @@ -100,14 +103,14 @@ def test_request_ssl_verification(client): client.request("test/path") args, kwargs = mock_request.call_args - assert kwargs['verify'] == True # Default is True + assert kwargs['verify'] # Default is True # Test with SSL verification disabled client.config.allow_unverified_ssl = True client.request("test/path") args, kwargs = mock_request.call_args - assert kwargs['verify'] == False + assert not kwargs['verify'] def test_request_with_payload(client): """Test request with payload data""" @@ -154,7 +157,6 @@ def test_post_telemetry_events_sends_individually(client): def test_post_telemetry_events_continues_on_failure(client): """Test that a failed event does not prevent subsequent events from being sent""" - import json events = [ {"event_kind": "user-action", "artifact_purl": "pkg:npm/foo@1.0.0"}, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index c1443adc..47bb3d88 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1,9 +1,11 @@ +import tomllib from pathlib import Path + import pytest -from unittest.mock import patch -import tomllib -from socketsecurity.core.socket_config import SocketConfig + from socketsecurity.config import CliConfig +from socketsecurity.core.socket_config import SocketConfig + def test_config_default_values(): """Test that config initializes with correct default values""" diff --git a/tests/unit/test_disable_ignore.py b/tests/unit/test_disable_ignore.py index e151a3f4..93f9579f 100644 --- a/tests/unit/test_disable_ignore.py +++ b/tests/unit/test_disable_ignore.py @@ -1,6 +1,5 @@ """Tests for the --disable-ignore flag.""" -import pytest from dataclasses import dataclass from socketsecurity.config import CliConfig @@ -8,7 +7,6 @@ from socketsecurity.core.messages import Messages from socketsecurity.core.scm_comments import Comments - # --- CLI flag parsing tests --- class TestDisableIgnoreFlag: diff --git a/tests/unit/test_gitlab_auth.py b/tests/unit/test_gitlab_auth.py index 3c6d6ddd..42c5e6bc 100644 --- a/tests/unit/test_gitlab_auth.py +++ b/tests/unit/test_gitlab_auth.py @@ -1,7 +1,8 @@ """Tests for GitLab authentication patterns""" import os +from unittest.mock import patch + import pytest -from unittest.mock import patch, MagicMock from socketsecurity import USER_AGENT from socketsecurity.core.scm.gitlab import GitlabConfig diff --git a/tests/unit/test_gitlab_auth_fallback.py b/tests/unit/test_gitlab_auth_fallback.py index d439da29..441f4cb3 100644 --- a/tests/unit/test_gitlab_auth_fallback.py +++ b/tests/unit/test_gitlab_auth_fallback.py @@ -1,9 +1,10 @@ """Integration test demonstrating GitLab authentication fallback""" import os -from unittest.mock import patch, MagicMock +from unittest.mock import MagicMock, patch + import pytest -from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig +from socketsecurity.core.scm.gitlab import Gitlab from socketsecurity.socketcli import CliClient @@ -37,7 +38,7 @@ def test_fallback_from_private_token_to_bearer(self): gitlab = Gitlab(client=mock_client) # This should trigger the fallback mechanism - result = gitlab.get_comments_for_pr() + gitlab.get_comments_for_pr() # Verify two requests were made assert mock_client.request.call_count == 2 @@ -79,7 +80,7 @@ def test_fallback_from_bearer_to_private_token(self): gitlab = Gitlab(client=mock_client) # This should trigger the fallback mechanism - result = gitlab.get_comments_for_pr() + gitlab.get_comments_for_pr() # Verify two requests were made assert mock_client.request.call_count == 2 @@ -140,7 +141,7 @@ def test_successful_first_attempt_no_fallback(self): gitlab = Gitlab(client=mock_client) # This should succeed on first try - result = gitlab.get_comments_for_pr() + gitlab.get_comments_for_pr() # Verify only one request was made assert mock_client.request.call_count == 1 diff --git a/tests/unit/test_gitlab_commit_status.py b/tests/unit/test_gitlab_commit_status.py index fc57ed6e..c02aa5b9 100644 --- a/tests/unit/test_gitlab_commit_status.py +++ b/tests/unit/test_gitlab_commit_status.py @@ -1,7 +1,5 @@ """Tests for GitLab commit status integration""" -import os -import pytest -from unittest.mock import patch, MagicMock, call +from unittest.mock import MagicMock, patch from socketsecurity.core.scm.gitlab import Gitlab, GitlabConfig diff --git a/tests/unit/test_gitlab_format.py b/tests/unit/test_gitlab_format.py index 96218e4e..4a1cf0c1 100644 --- a/tests/unit/test_gitlab_format.py +++ b/tests/unit/test_gitlab_format.py @@ -1,8 +1,7 @@ import re -import pytest -from socketsecurity.core.messages import Messages from socketsecurity.core.classes import Diff, Issue +from socketsecurity.core.messages import Messages class TestGitLabFormat: diff --git a/tests/unit/test_include_dirs.py b/tests/unit/test_include_dirs.py index 7261f697..54252875 100644 --- a/tests/unit/test_include_dirs.py +++ b/tests/unit/test_include_dirs.py @@ -6,8 +6,6 @@ import types from unittest.mock import MagicMock -import pytest - from socketsecurity.config import CliConfig from socketsecurity.core import Core from socketsecurity.core.socket_config import ( diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index 8cae52ba..68a63ec7 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -2,10 +2,12 @@ import pytest -from socketsecurity.core.classes import Diff, Package from socketsecurity import socketcli -from socketsecurity.socketcli import build_license_artifact_payload, should_write_comment - +from socketsecurity.core.classes import Diff, Package +from socketsecurity.socketcli import ( + build_license_artifact_payload, + should_write_comment, +) # --------------------------------------------------------------------------- # Exit-code-on-api-error (flag-only, non-breaking for 2.3.x). From 536d3a59ffa2d42f7229e2a5d421b1bb90c14bc1 Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:52:14 +0200 Subject: [PATCH 05/21] Bump pinned @coana-tech/cli to 15.10.39 (#347) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 8 ++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 15 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1007a3b9..40b9cd45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## 2.7.2 + +### Changed: bump pinned @coana-tech/cli to 15.10.39 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.36` to + `15.10.39`. See the [Coana changelogs](https://docs.coana.tech/changelogs) for + engine changes. + ## 2.7.1 ### Changed: bump pinned @coana-tech/cli to 15.10.36 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 0d4d4833..d661bdbb 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.36 | 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.39 | 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 3dcd5718..c848f2f0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.1" +version = "2.7.2" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 78220a1f..9a275469 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.1' +__version__ = '2.7.2' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 16ddfb1e..f43ce5c8 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -19,7 +19,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.36" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.39" # 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 @@ -56,7 +56,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.36'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.39'). Args: version: Coana CLI version to use. @@ -65,7 +65,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.36'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.39'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index d0338009..03044024 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.1" +version = "2.7.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 9aebab429dd464d73d9cdc414cdfbe1ffb9dd016 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:37:26 -0400 Subject: [PATCH 06/21] chore(deps): bump brotlicffi from 1.2.0.1 to 1.2.0.2 (#340) Bumps [brotlicffi](https://github.com/python-hyper/brotlicffi) from 1.2.0.1 to 1.2.0.2. - [Changelog](https://github.com/python-hyper/brotlicffi/blob/main/HISTORY.rst) - [Commits](https://github.com/python-hyper/brotlicffi/compare/v1.2.0.1...v1.2.0.2) --- updated-dependencies: - dependency-name: brotlicffi dependency-version: 1.2.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 2 +- uv.lock | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index c848f2f0..6ddc7222 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ dependencies = [ "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'", + "brotlicffi==1.2.0.2; platform_python_implementation != 'CPython'", ] readme = "README.md" description = "Socket Security CLI for CI/CD" diff --git a/uv.lock b/uv.lock index 03044024..269285da 100644 --- a/uv.lock +++ b/uv.lock @@ -173,27 +173,27 @@ wheels = [ [[package]] name = "brotlicffi" -version = "1.2.0.1" +version = "1.2.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/b6/017dc5f852ed9b8735af77774509271acbf1de02d238377667145fcee01d/brotlicffi-1.2.0.1.tar.gz", hash = "sha256:c20d5c596278307ad06414a6d95a892377ea274a5c6b790c2548c009385d621c", size = 478156, upload-time = "2026-03-05T19:54:11.547Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/97/7845739a36828ffe751a1c6b240692f552fd7ecf65026c51326c0a4aa369/brotlicffi-1.2.0.2.tar.gz", hash = "sha256:5e0fbd13644cf1f6015e75fa5e0ad8fdce1048d9c9ff90b0ce826174b249ee35", size = 478755, upload-time = "2026-08-21T17:29:18.415Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/f9/dfa56316837fa798eac19358351e974de8e1e2ca9475af4cb90293cd6576/brotlicffi-1.2.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c85e65913cf2b79c57a3fdd05b98d9731d9255dc0cb696b09376cc091b9cddd", size = 433046, upload-time = "2026-03-05T19:53:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/4a/f5/f8f492158c76b0d940388801f04f747028971ad5774287bded5f1e53f08d/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:535f2d05d0273408abc13fc0eebb467afac17b0ad85090c8913690d40207dac5", size = 1541126, upload-time = "2026-03-05T19:53:48.248Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e1/ff87af10ac419600c63e9287a0649c673673ae6b4f2bcf48e96cb2f89f60/brotlicffi-1.2.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ce17eb798ca59ecec67a9bb3fd7a4304e120d1cd02953ce522d959b9a84d58ac", size = 1541983, upload-time = "2026-03-05T19:53:50.317Z" }, - { url = "https://files.pythonhosted.org/packages/47/c0/80ecd9bd45776109fab14040e478bf63e456967c9ddee2353d8330ed8de1/brotlicffi-1.2.0.1-cp314-cp314t-win32.whl", hash = "sha256:3c9544f83cb715d95d7eab3af4adbbef8b2093ad6382288a83b3a25feb1a57ec", size = 349047, upload-time = "2026-03-05T19:53:52.215Z" }, - { url = "https://files.pythonhosted.org/packages/ab/98/13e5b250236a281b6cd9e92a01ee1ae231029fa78faee932ef3766e1cb24/brotlicffi-1.2.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:625f8115d32ae9c0740d01ea51518437c3fbaa3e78d41cb18459f6f7ac326000", size = 385652, upload-time = "2026-03-05T19:53:53.892Z" }, - { url = "https://files.pythonhosted.org/packages/9a/9f/b98dcd4af47994cee97aebac866996a006a2e5fc1fd1e2b82a8ad95cf09c/brotlicffi-1.2.0.1-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:91ba5f0ccc040f6ff8f7efaf839f797723d03ed46acb8ae9408f99ffd2572cf4", size = 432608, upload-time = "2026-03-05T19:53:56.736Z" }, - { url = "https://files.pythonhosted.org/packages/b1/7a/ac4ee56595a061e3718a6d1ea7e921f4df156894acffb28ed88a1fd52022/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9a670c6811af30a4bd42d7116dc5895d3b41beaa8ed8a89050447a0181f5ce", size = 1534257, upload-time = "2026-03-05T19:53:58.667Z" }, - { url = "https://files.pythonhosted.org/packages/99/39/e7410db7f6f56de57744ea52a115084ceb2735f4d44973f349bb92136586/brotlicffi-1.2.0.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f3314a3476f59e5443f9f72a6dff16edc0c3463c9b318feaef04ae3e4683f5a", size = 1536838, upload-time = "2026-03-05T19:54:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/a6/75/6e7977d1935fc3fbb201cbd619be8f2c7aea25d40a096967132854b34708/brotlicffi-1.2.0.1-cp38-abi3-win32.whl", hash = "sha256:82ea52e2b5d3145b6c406ebd3efb0d55db718b7ad996bd70c62cec0439de1187", size = 343337, upload-time = "2026-03-05T19:54:02.446Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ef/e7e485ce5e4ba3843a0a92feb767c7b6098fd6e65ce752918074d175ae71/brotlicffi-1.2.0.1-cp38-abi3-win_amd64.whl", hash = "sha256:da2e82a08e7778b8bc539d27ca03cdd684113e81394bfaaad8d0dfc6a17ddede", size = 379026, upload-time = "2026-03-05T19:54:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/7f/53/6262c2256513e6f530d81642477cb19367270922063eaa2d7b781d8c723d/brotlicffi-1.2.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e015af99584c6db1490a69a210c765953e473e63adc2d891ac3062a737c9e851", size = 402265, upload-time = "2026-03-05T19:54:05.858Z" }, - { url = "https://files.pythonhosted.org/packages/1f/d9/d5340b43cf5fbe7fe5a083d237e5338cc1caa73bea523be1c5e452c26290/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:37cb587d32bf7168e2218c455e22e409ad1f3157c6c71945879a311f3e6b6abf", size = 406710, upload-time = "2026-03-05T19:54:07.272Z" }, - { url = "https://files.pythonhosted.org/packages/a3/82/dbced4c1e0792efdf23fd90ff6d2a320c64ff4dfef7aacc85c04fde9ddd2/brotlicffi-1.2.0.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d6ba65dd528892b4d9960beba2ae011a753620bcfc66cf6fa3cee18d7b0baa4", size = 402787, upload-time = "2026-03-05T19:54:08.73Z" }, - { url = "https://files.pythonhosted.org/packages/ef/6f/534205ba7590c9a8716a614f270c5c2ec419b5b7079b3f9cd31b7b5580de/brotlicffi-1.2.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f2a5575653b0672638ba039b82fda56854934d7a6a24d4b8b5033f73ab43cbc1", size = 375108, upload-time = "2026-03-05T19:54:10.079Z" }, + { url = "https://files.pythonhosted.org/packages/77/a2/edda4f3fc7143434402eacad1e91433fe68ae648c22738eeddb6138638ba/brotlicffi-1.2.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad05ca993234cf947f0ad71b1c8bc0af3d74e0410b1e2c32bb99de0cef6a994b", size = 438789, upload-time = "2026-08-21T17:28:55.708Z" }, + { url = "https://files.pythonhosted.org/packages/0d/9c/506dc8edabb3cf9339c89f1ecc80a218aa166bb83b9f2e9cc1da67314072/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0636cb5a85f31c36e08953d09a226cb788be900b976f81302895e3cf35d5e707", size = 1541246, upload-time = "2026-08-21T17:28:57.669Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d6/74cee9f9fbea8c42030a81056c64e092030a95bd2756ea83da1d1e8f5f29/brotlicffi-1.2.0.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:97bae40d45ebc2a6ac7b1c9b30825496a257192194b672ef5869e2df93467f69", size = 1542129, upload-time = "2026-08-21T17:28:59.502Z" }, + { url = "https://files.pythonhosted.org/packages/24/cc/c32630b042ec2a13e8342e6ecb6b9d3531b1be4647b733d6fd365976041c/brotlicffi-1.2.0.2-cp314-cp314t-win32.whl", hash = "sha256:8f3f9bd61293dc48359763e693951393f39656086315067cf97e23e23e8911ab", size = 346840, upload-time = "2026-08-21T17:29:01.085Z" }, + { url = "https://files.pythonhosted.org/packages/ee/0b/83cac3075721fe4c253ea1cc5310cb687c2f7d987e0fd60eb3ed769c24c0/brotlicffi-1.2.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:908add8a9c0eea00f5de799dc6de9f6d205d9ee11afabc7c03d6812c481200e2", size = 386079, upload-time = "2026-08-21T17:29:02.667Z" }, + { url = "https://files.pythonhosted.org/packages/2e/71/c27f24b8334f65f2492601c7764338f156cb904d2ffe0061e6004a76d9cc/brotlicffi-1.2.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d5a8ffa154f16660ab818d78045b55fa6f9970f1ca4c38998766e99c672071cb", size = 438885, upload-time = "2026-08-21T17:29:04.113Z" }, + { url = "https://files.pythonhosted.org/packages/ef/22/d8fd1a4d09b7ab563b89380395e09151d2ef1344be31594df6a6987d4028/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ec6b1af7b7a8ce788354f2c603651ada0fba166ec31ab879e2eec462a3e6dbf4", size = 1534365, upload-time = "2026-08-21T17:29:05.878Z" }, + { url = "https://files.pythonhosted.org/packages/06/78/076419ed6c2c6aa3eaac6fd6b076502b4be89d50625fcdc513cd4aeca718/brotlicffi-1.2.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22916101de0e7ff535f2edf54b52a85591853b8ae9a98737643defdd3c063a3a", size = 1536851, upload-time = "2026-08-21T17:29:07.599Z" }, + { url = "https://files.pythonhosted.org/packages/35/dd/31ae9945cbd605339fb51c9a609f7dbb182cd361adeabc1d470142357206/brotlicffi-1.2.0.2-cp39-abi3-win32.whl", hash = "sha256:df1d34c4ad9adbf7f63a6b42f7d0e4dfd259c88141b85145b57abecc1abc3b24", size = 342379, upload-time = "2026-08-21T17:29:09.05Z" }, + { url = "https://files.pythonhosted.org/packages/95/ae/afd54e744df93b51cc29f6a19beccf9998b25743d7177697390de10479d1/brotlicffi-1.2.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:489ca4da3ee65926d72bf01584b61088a9da6bdd1bb01b2040901e1beaffa8f0", size = 379761, upload-time = "2026-08-21T17:29:10.687Z" }, + { url = "https://files.pythonhosted.org/packages/37/da/a5b65a86725d772504a348193cf1fab5ad6410794b422bf81faa17a96a66/brotlicffi-1.2.0.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:cf500bb9e02e1474ced1ecf22f74c568de2816b3627af6352ec51ac5e09e60ee", size = 407459, upload-time = "2026-08-21T17:29:12.385Z" }, + { url = "https://files.pythonhosted.org/packages/e1/c7/a253288e66ee340f2f6320eda7022daa723f2918438d586a59e9c998aa27/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbb81489562dd5363bf86d9a8edb0ec8c97049b0819ba4936fc023e8847248bc", size = 406825, upload-time = "2026-08-21T17:29:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6c/ea8e3d34e1d64c5e5a920bb0c89bf9e92badf973937a60922820395e622d/brotlicffi-1.2.0.2-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc7647657e4f3d73eab591910dbecb57d1ecaea7aa3dd04e6d704a2756fe0c59", size = 402903, upload-time = "2026-08-21T17:29:15.524Z" }, + { url = "https://files.pythonhosted.org/packages/4e/17/17c22d48819001ca08cadab63b09b00e0c56a7579478aa7c2623f4280de6/brotlicffi-1.2.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:5eb5563173afb92c9111b180349ff17d7c83c79febabadca5de983b552565c3c", size = 378395, upload-time = "2026-08-21T17:29:16.857Z" }, ] [[package]] @@ -714,7 +714,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.13'" }, + { name = "zipp" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1323,7 +1323,7 @@ dev = [ requires-dist = [ { 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 = "brotlicffi", marker = "platform_python_implementation != 'CPython'", specifier = "==1.2.0.2" }, { name = "gitpython", specifier = "==3.1.59" }, { name = "hatch", marker = "extra == 'dev'", specifier = "==1.18.0" }, { name = "markdown", specifier = "==3.10.3" }, From f0b8a9fbb096ff94215a19b299dbc09b51b0fbb2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:13:23 -0400 Subject: [PATCH 07/21] chore(deps): bump the python-minor-patch group across 1 directory with 2 updates (#339) Bumps the python-minor-patch group with 2 updates in the / directory: [ruff](https://github.com/astral-sh/ruff) and [uv](https://github.com/astral-sh/uv). Updates `ruff` from 0.16.4 to 0.16.5 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.4...0.16.5) Updates `uv` from 0.12.5 to 0.12.8 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.5...0.12.8) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.5 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: uv dependency-version: 0.12.7 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 +-- uv.lock | 90 +++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6ddc7222..c5e4da96 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,9 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.4", + "ruff==0.16.5", "twine==7.0.0", # for building - "uv==0.12.5", # for dependency management + "uv==0.12.8", # for dependency management "pre-commit==4.6.2", "hatch==1.18.0" ] diff --git a/uv.lock b/uv.lock index 269285da..4248208e 100644 --- a/uv.lock +++ b/uv.lock @@ -1213,27 +1213,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, - { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, - { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, - { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, - { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, - { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, - { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, - { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, - { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, - { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, - { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, - { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, - { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, - { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, - { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, - { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, - { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, +version = "0.16.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, + { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, + { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, + { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, + { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, + { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, + { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, + { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, + { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, + { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, + { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, + { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, ] [[package]] @@ -1338,10 +1338,10 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.4" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, { name = "socketdev", specifier = "==3.5.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, - { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.5" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, ] provides-extras = ["test", "dev"] @@ -1494,28 +1494,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7c/b0/3085b844fe59aa319a3f94a5cca9938fffecc82705aa9c2762a749f7095c/uv-0.12.5.tar.gz", hash = "sha256:442a21d181faae21742aaaf6d2091a0d27755d3eac344061a9a00c90169b7524", size = 7101936, upload-time = "2026-08-14T19:56:57.693Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/4c/6412d4a618230db699118b362ec41c54795f93992b43c53e225bd0213501/uv-0.12.5-py3-none-linux_armv6l.whl", hash = "sha256:2bd62134e56af35b9cf017aaf8ae41a605d6501dd49afc35b70b544a45dd8354", size = 23310055, upload-time = "2026-08-14T19:55:51.357Z" }, - { url = "https://files.pythonhosted.org/packages/bd/ec/d76387b388fa21620088b89b9c67f2596a707add585104e0cb5e8abf55f2/uv-0.12.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:1a06c8bc4d43b5f6c1e3f2ae3d0f6455b07515f762516f95e52e6c0cbccedf15", size = 21401335, upload-time = "2026-08-14T19:55:55.371Z" }, - { url = "https://files.pythonhosted.org/packages/6d/bc/81ab953b7261ae6be40874b1f283a10873871e02eb353d354614dd8da96b/uv-0.12.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d87156bc174d94fae890bb7a261e2867140abb9fe1e9de81a5295e582fb9d0f5", size = 19290641, upload-time = "2026-08-14T19:55:58.998Z" }, - { url = "https://files.pythonhosted.org/packages/7d/13/07585043c10e648820bf826474dac46864ce6691da5dc52fee43c5c7523a/uv-0.12.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:2d65b7b3bc3fd28678f62aa7fb5d90f106ad9782c1354af60b6cecdf9ea9ecd9", size = 22245569, upload-time = "2026-08-14T19:56:02.729Z" }, - { url = "https://files.pythonhosted.org/packages/3e/6d/310f8f56f8d001b4000112a09d7b7de80fb2024a90208fabb9ddc457c123/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:712624b62e25c84e5a10fc6aa144d8a81b685fdc067a54a7ca4367d75d2cf791", size = 22745152, upload-time = "2026-08-14T19:56:06.426Z" }, - { url = "https://files.pythonhosted.org/packages/92/da/7922b67eec5ee03e94333c5841b682c335033ee80acac17c3417bd752656/uv-0.12.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f9656ac7a00fd4314980fb0f790df1c1f3fa9cbcf9af9c6f611b19448b9da687", size = 22787947, upload-time = "2026-08-14T19:56:10.149Z" }, - { url = "https://files.pythonhosted.org/packages/62/55/5dbaed832a4b36809ef8a07c8e56e9fee0dedb0aa0454f6d232b6e468f2c/uv-0.12.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:568485b44e848eb3693f85d6b00299ccd8fc4d26902030dbf24f549c276db9ca", size = 23367616, upload-time = "2026-08-14T19:56:13.768Z" }, - { url = "https://files.pythonhosted.org/packages/11/77/baf761d12bb66efb01706e3bbb5926ed0d13cb0a40539a661fcfffd46de4/uv-0.12.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bd08c82831b0033330f8eeeb0d90f938a4d999f25569bee68a975c736142d795", size = 24586263, upload-time = "2026-08-14T19:56:17.57Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a8/76c1031c4834c959bb8a8059c9feabeaa77488ce8b6a3529d6d929ae81cf/uv-0.12.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:edd9ff6154b891146a342c143cd29b330ad97ac6a4b20ff4a99a20a4da84ceca", size = 24160655, upload-time = "2026-08-14T19:56:21.568Z" }, - { url = "https://files.pythonhosted.org/packages/93/22/dacc9a0bc8604187a1ba954a3aef8329e4104eb0af772d2c3c634893bd9b/uv-0.12.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e195ccf1ed60c8bb24a6447ce306441a4181d54b602407e09bc56e963911c15", size = 23657089, upload-time = "2026-08-14T19:56:25.144Z" }, - { url = "https://files.pythonhosted.org/packages/39/98/e8f9c071622f2cb4072d8b587d27b27d23cf0d3ebf8b3687f5af6030f587/uv-0.12.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:58abfb0f658b39a834307a11223bc170294ea214263b4c99ecc7663720d43544", size = 22379954, upload-time = "2026-08-14T19:56:28.789Z" }, - { url = "https://files.pythonhosted.org/packages/73/95/4c3f060e95f7cbe9177b4ab361f0cbfc4ae22e5a49b22e73eee9f0d0a6ca/uv-0.12.5-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:6ad2c455f1fe4d2962f6fd7ccb3b1f61c61856681c9d99f40e170b2074353fa3", size = 23318163, upload-time = "2026-08-14T19:56:32.504Z" }, - { url = "https://files.pythonhosted.org/packages/a0/96/ca0497ef8912ef48dbbc9982a8b4212260c34d56bfd0d45fe67b31942121/uv-0.12.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a05b497c2a948c8600f4c831a89852b4d2514b7f561074225cc9edd0cc4811e2", size = 23470437, upload-time = "2026-08-14T19:56:36.525Z" }, - { url = "https://files.pythonhosted.org/packages/60/e7/8bdc37669a6cd2b46a2ec08ccbb58c61395ec84a073e199f5a4a64bb998f/uv-0.12.5-py3-none-musllinux_1_1_i686.whl", hash = "sha256:7817f8e957960f9ddc452ea353f283c0d6393e2e31b400276485adced5b1f371", size = 22545803, upload-time = "2026-08-14T19:56:40.606Z" }, - { url = "https://files.pythonhosted.org/packages/37/cc/01e39e1dbeb838a6b3c26bf97c867d6f366459b22a38bea691af8c6c94c0/uv-0.12.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:dc14e4f81a99b585a891350c60d1ff4557d54cb3c3c81fa45fd4e0dd512ba752", size = 23874113, upload-time = "2026-08-14T19:56:44.193Z" }, - { url = "https://files.pythonhosted.org/packages/0a/38/9053599a73a351d1cd34195c7a48c1db4d4d51b57b543607fad7ecf9354c/uv-0.12.5-py3-none-win32.whl", hash = "sha256:39bb102766c95571781a7b4c611675ea213e08df5c680f3936279b3c0d1f6c3c", size = 20744641, upload-time = "2026-08-14T19:56:47.689Z" }, - { url = "https://files.pythonhosted.org/packages/ce/f6/a9af9311c7f5640ca2bfcfdedb7aca37fa6d1d9f5c981fb50c5be02b7477/uv-0.12.5-py3-none-win_amd64.whl", hash = "sha256:455c3e57602e2141e66e2f0bf685898c9c5e5a70377d14c9a71554a3baf3ddbf", size = 21621812, upload-time = "2026-08-14T19:56:51.126Z" }, - { url = "https://files.pythonhosted.org/packages/bc/fb/e1266399f755f97a0783de379f2fed6dae0a2a240db32fe5a2eb976fec8a/uv-0.12.5-py3-none-win_arm64.whl", hash = "sha256:bea86f27a027e0e3af908db4bdd4f1ceef3ca2bd47673b5ccca7f550e325b1b4", size = 20381876, upload-time = "2026-08-14T19:56:54.883Z" }, +version = "0.12.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b1/38/53754ae5033d0dbfe01976249e4b1b48539fe5080aeec162bc920f6244d6/uv-0.12.8.tar.gz", hash = "sha256:dc6a191265fed5102d9678958b3a157c81e61246316f07e7d980c3616032f49d", size = 7124914, upload-time = "2026-08-31T22:18:18.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/13/8e6984272962a273323c58113ae390eb709f7a74726009e1bcecbb7fe0fd/uv-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:e6c617d5674867653738eaec333025142e9de33410955f8f6ae40e2e8ac85e77", size = 22245394, upload-time = "2026-08-31T22:17:24.331Z" }, + { url = "https://files.pythonhosted.org/packages/0d/38/18d7ae5d4b40714b8e2c474bfcac907a8dd27573044b60e808b895a07423/uv-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:edfb42bc830b4c473653ed5470ac4d967ae2cd40c1b7409ec4c7c8b655d04235", size = 20544653, upload-time = "2026-08-31T22:17:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/84/32/249b0ec1a62916b8e7c2638154b930daaf9eae2df8b0807ad455c8916b7e/uv-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:842e27b85ec85c32369919b6dc72a4ae0c737b4376165d34a6ebb8329ce9d744", size = 17363409, upload-time = "2026-08-31T22:17:30.309Z" }, + { url = "https://files.pythonhosted.org/packages/62/4c/74ba60fbcda11be5aa5a7f139bc122834ec740d735e155ad9324aff3570a/uv-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ff4d67a13a3283004e3b00e22c39922c27b0e1929f82f9d6873dc85dc8bf6dc7", size = 21484891, upload-time = "2026-08-31T22:17:33.144Z" }, + { url = "https://files.pythonhosted.org/packages/4a/92/6817f8f705bcf0e6b51302a55a899faf2a29d9c3f056cb04f9f133a7192b/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:a8a8129edee5591b09933b8387d3ccc250933a9cb39f98bde89d6e4f920a870d", size = 21731676, upload-time = "2026-08-31T22:17:36.323Z" }, + { url = "https://files.pythonhosted.org/packages/6f/df/7ca009329a0b2194ca709f51e1f304b01086e0f99f3f8e0ea81460a193b9/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef0c05b39d8701b4bb8196aeaf2594147d8369a1026b0b731eead41cb1df1884", size = 21749846, upload-time = "2026-08-31T22:17:39.517Z" }, + { url = "https://files.pythonhosted.org/packages/ba/46/1e73455d1c4d8f9eafaac19879ba3fc5b8c0743d1b04d11ad1ac24e1a522/uv-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddaa3ceb3f877cce42e75084d10ebe53148163aaa4b1d16432c3b952e9c7466c", size = 22469076, upload-time = "2026-08-31T22:17:43.016Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a3/f11be2600c67345bfb694fd7c68dad97d4133d54306d298a2690b21da66b/uv-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e9b112000bd5ff8e8c97922f03acede7d1f2385b3261bc6a15d6b18871e9a25", size = 23653340, upload-time = "2026-08-31T22:17:46.23Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f9/09e1762b40451227fc944551c7ea62d13f9b29e4965bf23adffeaba0f29a/uv-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a2f848916a6e5b3111e5d4c9e6ac66d9e38c561da4fa3a4ff51691e3c55974c", size = 23346672, upload-time = "2026-08-31T22:17:49.145Z" }, + { url = "https://files.pythonhosted.org/packages/19/e7/6e1797aec65defdc02a81444c2d8964101876097c461993fa1e14641782e/uv-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d63d046051d33b36260146df5aef03e9166b30b4546e9b6e554be152b69f9f9", size = 20068890, upload-time = "2026-08-31T22:17:52.225Z" }, + { url = "https://files.pythonhosted.org/packages/62/b5/2662d5da5158e4db444a9e050f8f1eaecea2832311e2da27ac53a5d15ebb/uv-0.12.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7db4fb5d559bab30ea2310db9119cfd44bc438c09f24cb33d6b9a518fd671143", size = 19434317, upload-time = "2026-08-31T22:17:55.008Z" }, + { url = "https://files.pythonhosted.org/packages/f8/0f/fe154c14adc7af8e659784a59f34c2c0dda6462c1427ce6ac5fff2652391/uv-0.12.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:dc5990c941a7c064f761f13ddfd9a0de66a0063676e228403e13709e0254094f", size = 22454547, upload-time = "2026-08-31T22:17:58.008Z" }, + { url = "https://files.pythonhosted.org/packages/77/d9/8e3bd5733290cd8428db0995d1b9ef633760cf12ae6cdd437eddcae95d93/uv-0.12.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5e30eb88618e8ac315b6511bcd64bf4b40aa0737912ce9c2eb988dc6aa59473b", size = 22575842, upload-time = "2026-08-31T22:18:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/06/3f/c6c2331189bb92f72487d85c94d87af2b888f2fa9e95c4d3163247c2f850/uv-0.12.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8d3bd25b02887fd437fc8b308101c89b651ac43870579bb1a498d139b096582d", size = 21585514, upload-time = "2026-08-31T22:18:03.892Z" }, + { url = "https://files.pythonhosted.org/packages/8d/fa/094b6154e4fb624b3aeeb313fb6888e439e3d65092cf5fcbb4ea1e362c17/uv-0.12.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b8b589f36222ed73f070598d9cd68f7dc706cf5d670a8c469b648287a97a590a", size = 22850849, upload-time = "2026-08-31T22:18:06.887Z" }, + { url = "https://files.pythonhosted.org/packages/e9/aa/c0755ee526db93453b2a5b4079f46ece9819c9e26918be36187888c856c6/uv-0.12.8-py3-none-win32.whl", hash = "sha256:a9ddf59627e0718db0e84b329005462f335fe707723813036322f00133d992fa", size = 19862492, upload-time = "2026-08-31T22:18:09.864Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f2/3564ae1d1d37b66dc64e6be52f5e5fe00cfce235b6f60d0b708c6caf7588/uv-0.12.8-py3-none-win_amd64.whl", hash = "sha256:53984d68cddd227e6433b70b510d4b47fe82e658f22f4a6ca416b4e373406393", size = 18084781, upload-time = "2026-08-31T22:18:12.77Z" }, + { url = "https://files.pythonhosted.org/packages/c4/b4/724322de5a51ec8c59e3e8d90eeaa8f7c7d0b3b58390805908b87dfd57d7/uv-0.12.8-py3-none-win_arm64.whl", hash = "sha256:71763f479286a9e3285642fba8fca710aea9596d56e72bc247ecdf882e05ebc4", size = 19526211, upload-time = "2026-08-31T22:18:15.521Z" }, ] [[package]] From 86e3c1db580cb56cc8eb5dde6d9e019f07f6aa80 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:14:45 -0400 Subject: [PATCH 08/21] ci(deps): bump docker/setup-buildx-action (#338) Bumps the github-actions-minor-patch group with 1 update in the /.github/actions/setup-docker directory: [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action). Updates `docker/setup-buildx-action` from 4.2.0 to 4.3.0 - [Release notes](https://github.com/docker/setup-buildx-action/releases) - [Commits](https://github.com/docker/setup-buildx-action/compare/bb05f3f5519dd87d3ba754cc423b652a5edd6d2c...37fe631027851001ddb9b187196cc803df7f5f0e) --- updated-dependencies: - dependency-name: docker/setup-buildx-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 58239ff6..66abffdc 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -21,7 +21,7 @@ runs: steps: - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 if: inputs.enable-qemu == 'true' - - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ inputs.dockerhub-username }} From 9e56d79d0bc7ad2b489051b1793a5cc6734706d2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:22:18 -0400 Subject: [PATCH 09/21] chore(deps): bump httpcore2 from 2.9.1 to 2.10.0 (#348) Bumps [httpcore2](https://github.com/pydantic/httpx2) from 2.9.1 to 2.10.0. - [Release notes](https://github.com/pydantic/httpx2/releases) - [Commits](https://github.com/pydantic/httpx2/compare/v2.9.1...v2.10.0) --- updated-dependencies: - dependency-name: httpcore2 dependency-version: 2.10.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/uv.lock b/uv.lock index 4248208e..dd56af47 100644 --- a/uv.lock +++ b/uv.lock @@ -3,7 +3,8 @@ revision = 3 requires-python = ">=3.11" resolution-markers = [ "python_full_version >= '3.13'", - "python_full_version < '3.13'", + "python_full_version == '3.12.*' and sys_platform == 'emscripten'", + "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')", ] [[package]] @@ -640,31 +641,41 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.9.1" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/a8/20ed1ed79cbc2ecdf5301c0968ab7c85547212e2a7bd126ddd2d986e206e/httpcore2-2.9.1.tar.gz", hash = "sha256:4d8acbf8b306f48c9d6046591fd5ba4037d1b1b1000d140fc2c3eab1e9a0c0e2", size = 67089, upload-time = "2026-07-24T09:21:03.867Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/fb/46c52b781975c335a2bcf1072c7bbc007cbdc8d674217f5ee1daba2c848b/httpcore2-2.9.1-py3-none-any.whl", hash = "sha256:6182472379e855fe4221246a2bb7ecede403bc61c6798062ae1787d051ccde26", size = 82809, upload-time = "2026-07-24T09:21:01.178Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, ] [[package]] name = "httpx2" -version = "2.9.1" +version = "2.10.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio" }, - { name = "httpcore2" }, + { name = "anyio", marker = "sys_platform != 'emscripten'" }, + { name = "httpcore2", marker = "sys_platform != 'emscripten'" }, + { name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" }, { name = "idna" }, - { name = "truststore" }, + { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/14/38128fbafd7e0ed41d874df6c9a653d47c2d111cfe59e2b4ac95161b4abd/httpx2-2.9.1.tar.gz", hash = "sha256:1932a768737e3666291582833da748cc4e563c337cf96706fccc04fa6e58764a", size = 95458, upload-time = "2026-07-24T09:21:04.972Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, +] + +[[package]] +name = "httpx2-jsfetch" +version = "1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/b8/cfd91c4ab9134d386d48f0b6ac662ff3d4be6efdee59ee1c67ebc3c0487c/httpx2-2.9.1-py3-none-any.whl", hash = "sha256:1820fe14a9ab1107bfeff39259987429450b070ec0ff38cc87eb0d8c97fdc71a", size = 91191, upload-time = "2026-07-24T09:21:02.6Z" }, + { url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" }, ] [[package]] From e50b3aa4fd33f02fcf4c25e06b58c6312160e7e8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 9 Sep 2026 13:54:26 -0400 Subject: [PATCH 10/21] chore(deps): bump httpx2 from 2.10.0 to 2.12.0 (#349) Bumps [httpx2](https://github.com/pydantic/httpx2) from 2.10.0 to 2.12.0. - [Release notes](https://github.com/pydantic/httpx2/releases) - [Changelog](https://github.com/pydantic/httpx2/blob/main/src/httpx2/CHANGELOG.md) - [Commits](https://github.com/pydantic/httpx2/compare/v2.10.0...v2.12.0) --- updated-dependencies: - dependency-name: httpx2 dependency-version: 2.12.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- uv.lock | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/uv.lock b/uv.lock index dd56af47..80dd5713 100644 --- a/uv.lock +++ b/uv.lock @@ -641,20 +641,20 @@ wheels = [ [[package]] name = "httpcore2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "h11" }, { name = "truststore" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a9/83/a896fc59940fc5a6e2aff3a4be1d92fa890112936803b331cae75a993c34/httpcore2-2.10.0.tar.gz", hash = "sha256:13c0cc3d1919d4f28457f60cd2c2abe04113a8af184ccf1142811beba936f9dc", size = 67427, upload-time = "2026-08-09T09:11:32.123Z" } +sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/4f/d149104195a35e2853a2fc203a8e3477747e58c80e17dda686dace174383/httpcore2-2.10.0-py3-none-any.whl", hash = "sha256:7df06cfb34070cae4f7c89be69dc1095eca138e9704ceffb98d25c1912ab6f01", size = 83000, upload-time = "2026-08-09T09:11:29.555Z" }, + { url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" }, ] [[package]] name = "httpx2" -version = "2.10.0" +version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform != 'emscripten'" }, @@ -664,9 +664,9 @@ dependencies = [ { name = "truststore", marker = "sys_platform != 'emscripten'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bd/3d/f9a8c07a3884f3e5b26205e8436a18b3af61c5d53192c3bea235574dbbec/httpx2-2.10.0.tar.gz", hash = "sha256:8741d7329fe2c7885fc9ceb61c8217acfb87a85f75723714b89ebf7ad7196338", size = 98749, upload-time = "2026-08-09T09:11:33.24Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b9/6d/a637d52449d98a6892d9a4dc0262587afdb6a66f201871842dce5a97b1c1/httpx2-2.10.0-py3-none-any.whl", hash = "sha256:5e3194a432701e1cc6f69a8b1b2fa199ef907013fede8d9a09a2c5b7b8141a18", size = 94355, upload-time = "2026-08-09T09:11:30.882Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" }, ] [[package]] From f65b6aff5d77c9812fbbbba9278cd6a5d0fad1fa Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 18:36:35 -0400 Subject: [PATCH 11/21] Improve monorepo scan diagnostics and guidance (#325) * Improve monorepo scan diagnostics * Bump version to 2.6.9 * Bump version to 2.7.0 * docs: document the monorepo scan layout trade-off The mechanics of --sub-path and --workspace-name were documented, but not the choice they force. One combined scan gives a single dashboard entry and no per-component attribution; one scan per component gives attribution, baselines and per-component policy, but adds a repository entry per component, which grows the dashboard's repository list. There is no layout that provides both today. Customers hit this at a dozen-plus components and reasonably assume they have configured something wrong. Naming the trade-off, and adding rules of thumb for picking a side, is cheaper than each of them discovering it. Cross-referenced from the CI/CD guide's independent-workspace pattern, which is the layout that grows the list. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 14 ++ README.md | 4 + docs/ci-cd.md | 233 ++++++++++++++++++++++++++++++++ docs/cli-reference.md | 75 +++++++++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/__init__.py | 77 +++++++++-- socketsecurity/socketcli.py | 16 +++ tests/core/test_sdk_methods.py | 32 +++++ tests/unit/test_scan_scope.py | 140 +++++++++++++++++++ tests/unit/test_socketcli.py | 14 ++ uv.lock | 2 +- 12 files changed, 592 insertions(+), 19 deletions(-) create mode 100644 tests/unit/test_scan_scope.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 40b9cd45..29dda5bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.8.0 + +### Changed: improve monorepo scan diagnostics and guidance + +- Added aggregate scan configuration, manifest-count, baseline-selection, and + fallback diagnostics without listing submitted manifest paths. +- Clarified monorepo scan scoping, workspace flags, CI path filters, and timeout + behavior, with a changed-workspace GitHub Actions example. + +### Fixed: apply configured exit codes to API failures + +- Full-scan and streamed-diff API failures now use the configured infrastructure + error exit code instead of the security-finding exit code. + ## 2.7.2 ### Changed: bump pinned @coana-tech/cli to 15.10.39 diff --git a/README.md b/README.md index cda9a407..5f978d59 100644 --- a/README.md +++ b/README.md @@ -229,6 +229,10 @@ value โ€” e.g. a Buildkite code, or `0` to swallow infra errors. Exit `3` is a Socket convention, not an industry standard. +This mapping applies to errors the CLI receives and handles. An external process +supervisor (for example GNU `timeout`) can terminate the CLI before it handles an +error, so the supervisor's exit status (commonly 124 or 137) takes precedence. + ### How these options interact The two flags that affect exit codes can cancel each other out, so the order of diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 061d18ea..968799b5 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -71,6 +71,239 @@ Equivalent JSON: SOCKET_SECURITY_API_TOKEN: ${{ secrets.SOCKET_SECURITY_API_TOKEN }} ``` +#### GitHub Actions: scan changed monorepo workspaces independently + +GitHub Actions `paths` filters only decide whether a workflow starts. They do not +change `socketcli` discovery or upload scope. For a merge gate, it is usually safer +to start a small selector job on every PR update, then create one scan job per +affected logical workspace. This also avoids a required check remaining pending +when GitHub skips the entire workflow because of a top-level path filter. + +This pattern produces one dashboard entry per logical workspace, which is what +gives each component its own alerts, baseline, and policy. It is also the layout +that grows the dashboard's repository list. See +[Choosing a scan layout](cli-reference.md#choosing-a-scan-layout) for when that +trade-off is worth making. + +Define a repository variable named `SOCKET_MONOREPO_WORKSPACES_JSON`. Its value is +an array with one stable workspace name, one or more scan roots, and the path globs +that should select that workspace. Fill these placeholders with the repository's +real layout. A workspace definition selects directory roots; shared root manifests, +lockfiles, and cross-directory path dependencies outside those roots are not included +automatically. + +```json +[ + { + "name": "", + "sub_paths": [""], + "watch_globs": [""] + } +] +``` + +Each `sub_paths` value must be a directory, not an individual manifest or lockfile. +Using `.` includes the entire target path. Do not use this changed-workspace pattern +until the directory boundaries preserve every shared input needed to resolve each +logical graph. If root workspace metadata governs most or all of the repository, a +smaller coverage-preserving split may not be representable with `--sub-path` alone. + +Also define `SOCKETCLI_VERSION` as the exact package version validated for the +workflow. The workflow below logs that version, uses full Git history for reliable +base/head selection, creates one matrix job (and therefore one graph and baseline) +per selected workspace, and fails closed on CLI/API/timeout failures. It uses API +SCM mode plus `--enable-diff` because parallel `--scm github` jobs can race while +updating the same PR comments; the matrix checks and report links are the gate. + +```yaml +name: Socket Security + +on: + pull_request: + types: [opened, synchronize, reopened] + push: + branches: [main] + +permissions: + contents: read + +jobs: + select-workspaces: + runs-on: ubuntu-latest + outputs: + count: ${{ steps.select.outputs.count }} + matrix: ${{ steps.select.outputs.matrix }} + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - id: select + name: Select changed workspaces + env: + WORKSPACES_JSON: ${{ vars.SOCKET_MONOREPO_WORKSPACES_JSON }} + BASE_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + shell: bash + run: | + python - <<'PY' + import fnmatch + import json + import os + import re + import subprocess + + workspaces = json.loads(os.environ["WORKSPACES_JSON"]) + if not isinstance(workspaces, list): + raise SystemExit("SOCKET_MONOREPO_WORKSPACES_JSON must be a JSON array") + + base = os.environ["BASE_SHA"] + head = os.environ["HEAD_SHA"] + if not base or set(base) == {"0"}: + base = subprocess.check_output( + ["git", "rev-parse", f"{head}^"], text=True + ).strip() + changed_output = subprocess.check_output( + ["git", "diff", "--name-only", "-z", base, head] + ) + changed = [ + item.decode("utf-8", "surrogateescape") + for item in changed_output.split(b"\0") + if item + ] + + selected = [] + for workspace in workspaces: + name = workspace.get("name", "") + sub_paths = workspace.get("sub_paths") or [] + watch_globs = workspace.get("watch_globs") or [] + if not re.fullmatch(r"[A-Za-z0-9._-]+", name): + raise SystemExit(f"Invalid workspace name: {name!r}") + if not sub_paths or any( + not isinstance(path, str) + or path.startswith("/") + or ".." in path.split("/") + for path in sub_paths + ): + raise SystemExit(f"Invalid sub_paths for workspace {name!r}") + if not watch_globs: + watch_globs = [ + pattern + for path in sub_paths + for pattern in ( + ["*"] + if path.strip("/") in ("", ".") + else [path.rstrip("/"), f"{path.rstrip('/')}/*"] + ) + ] + if any( + fnmatch.fnmatchcase(path, pattern) + for path in changed + for pattern in watch_globs + ): + selected.append({"name": name, "sub_paths": sub_paths}) + + matrix = json.dumps({"include": selected}, separators=(",", ":")) + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as output: + output.write(f"count={len(selected)}\n") + output.write(f"matrix={matrix}\n") + PY + + scan-workspace: + needs: select-workspaces + if: needs.select-workspaces.outputs.count != '0' + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.select-workspaces.outputs.matrix) }} + name: Socket scan (${{ matrix.name }}) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-python@v6 + with: + python-version: '3.12' + + - name: Install pinned Socket CLI + env: + SOCKETCLI_VERSION: ${{ vars.SOCKETCLI_VERSION }} + run: | + python -m pip install "socketsecurity==$SOCKETCLI_VERSION" + socketcli --version + + - name: Scan workspace + env: + SOCKET_SECURITY_API_KEY: ${{ secrets.SOCKET_SECURITY_API_KEY }} + PR_NUMBER: ${{ github.event.pull_request.number || 0 }} + WORKSPACE_NAME: ${{ matrix.name }} + SUB_PATHS_JSON: ${{ toJSON(matrix.sub_paths) }} + shell: bash + run: | + set +e + args=( + --target-path "$GITHUB_WORKSPACE" + --workspace-name "$WORKSPACE_NAME" + --enable-diff + --pr-number "$PR_NUMBER" + --exit-code-on-api-error 3 + --report-link-file socket-report-link.txt + --summary-file socket-summary.txt + ) + while IFS= read -r sub_path; do + args+=(--sub-path "$sub_path") + done < <(jq -r '.[]' <<<"$SUB_PATHS_JSON") + + socketcli "${args[@]}" 2>&1 | tee socket-output.log + code=${PIPESTATUS[0]} + + { + echo "## Socket scan: $WORKSPACE_NAME" + if [ -s socket-report-link.txt ]; then + echo "[View the report]($(cat socket-report-link.txt))" + fi + if [ -s socket-summary.txt ]; then + echo '```' + cat socket-summary.txt + echo '```' + fi + } >> "$GITHUB_STEP_SUMMARY" + + exit "$code" + + socket-security: + if: always() + needs: [select-workspaces, scan-workspace] + runs-on: ubuntu-latest + steps: + - name: Enforce matrix result + env: + SELECT_RESULT: ${{ needs.select-workspaces.result }} + SCAN_RESULT: ${{ needs.scan-workspace.result }} + run: | + test "$SELECT_RESULT" = success + [[ "$SCAN_RESULT" = success || "$SCAN_RESULT" = skipped ]] +``` + +Each configuration object may intentionally contain several `sub_paths` when +those directories are one logical dependency graph. To split backend resolution, +use separate objects with different `name` values. Add `--workspace ` only +when the Socket organization requires API workspace association; it is not a scan +scope control. Use `--save-submitted-files-list` in a non-required canary to verify +the exact manifests selected before adopting workspace-level scans as a merge gate. + +The job has an explicit 20-minute total budget. Tune that value from observed +workspace-level latency after the split; a five-minute cap can still be too close +to a slow request plus local startup. The CLI's `--timeout` is different: it +defaults to 1,200 seconds **per API request**. If an operator adds GNU `timeout`, +that process supervisor can terminate the CLI before it maps an error through +`--exit-code-on-api-error`; without `--preserve-status`, GNU reports 124 after its +initial timeout signal or 137 if `SIGKILL` is involved. + ### Buildkite ```yaml diff --git a/docs/cli-reference.md b/docs/cli-reference.md index d661bdbb..e20c34b2 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -53,18 +53,67 @@ Pre-configured workflow files are in [`../workflows/`](../workflows/). > **Note:** If you're looking to associate a scan with a named Socket workspace (e.g. because your repo is identified as `org/repo`), see the [`--workspace` flag](#repository) instead. The `--workspace-name` flag described in this section is an unrelated monorepo feature. -The Socket CLI supports scanning specific workspaces within monorepo structures while preserving git context from the repository root. This is useful for organizations that maintain multiple applications or services in a single repository. +The Socket CLI supports scanning selected directories within a monorepo while preserving git context from the repository root. Scan scope is controlled by `--target-path` and `--sub-path`; CI workflow path filters and the CLI's changed-file detection do not narrow the manifests uploaded after a scan starts. ### Key Features -- **Multiple Sub-paths**: Specify multiple `--sub-path` options to scan different directories within your monorepo -- **Combined Workspace**: All sub-paths are scanned together as a single workspace in Socket +- **Target path**: Supplies repository/Git context and is the discovery root when no `--sub-path` is present +- **Multiple Sub-paths**: Restrict discovery to those directories, but combine every repeated `--sub-path` into one upload and one server-side dependency graph - **Git Context Preserved**: Repository metadata (commits, branches, etc.) comes from the main target-path -- **Workspace Naming**: Use `--workspace-name` to differentiate scans from different parts of your monorepo +- **Workspace Naming**: Use a stable, unique `--workspace-name` for each independently scanned logical workspace; it suffixes the repository slug and therefore gives that workspace its own repository head/baseline + +`--workspace` is different: it sends Socket organization workspace context with the full-scan API request. It does not narrow client-side filesystem discovery, split the upload into independent scans, or change the repository suffix. Backend policy/routing for that workspace remains server-owned. + +> **Performance consequence:** If the goal is smaller independently resolvable graphs, run one CLI invocation per logical workspace, with a distinct `--workspace-name`. Adding several unrelated directories to one command with repeated `--sub-path` flags still asks the backend to resolve one combined graph. + +Normal scan logs include the effective repository and Socket workspace context, +repository-relative discovery roots, aggregate manifest count, and selected baseline. +Individual manifest paths remain opt-in through `--save-submitted-files-list`. + +### Choosing a scan layout + +`--sub-path` and `--workspace-name` support two layouts, and picking between them +is a trade-off rather than a preference. There is no third option today. + +**One combined scan** โ€” a single invocation, no `--workspace-name`, with +`--target-path` at the repository root or several repeated `--sub-path` values +sharing one workspace name: + +- One dashboard entry for the repository, named after the repository +- One server-side dependency graph covering everything that was uploaded +- Alerts are **not** broken out by component, so a finding does not tell you which + part of the monorepo introduced it +- Transitive findings can surface without a clear owning component, because the + combined graph has no component boundaries to attribute them to + +**One scan per component** โ€” a separate invocation per component, each with its +own `--sub-path` and a distinct `--workspace-name`: + +- Per-component alerts, baselines, and policy +- Each component gets its own dependency graph, which is also the faster option + (see the performance note above) +- But `--workspace-name` suffixes the repository slug, so *N* components produce + *N* separate entries in the dashboard's repository list + +The second point is what makes this a real choice: a monorepo with a dozen or more +independently scanned components produces a dozen or more repository entries, which +gets hard to navigate as the list grows. A single consolidated entry that still +preserves per-component attribution is a known request and is not available today. + +Rules of thumb: + +- **Few components, or components that share a release cycle** โ€” use one combined + scan and accept coarser attribution. +- **Many components, or components with different owners or policies** โ€” use + per-component scans and accept the extra dashboard entries. Per-component policy + is only possible in this layout. +- **Components that are genuinely one application** โ€” group them under a single + `--workspace-name`, as in the first example below. Grouping is per logical + application, not per directory. ### Usage Examples -**Scan multiple frontend and backend workspaces:** +**Scan several directories that belong to one logical application:** ```bash socketcli --target-path /path/to/monorepo \ --sub-path frontend \ @@ -89,6 +138,19 @@ This will: - Create a repository in Socket named like `my-repo-mobile-web` - Preserve git context (commits, branch info) from the repository root +**Create independent frontend and backend scans:** +```bash +socketcli --target-path /path/to/monorepo \ + --sub-path frontend \ + --workspace-name frontend + +socketcli --target-path /path/to/monorepo \ + --sub-path backend \ + --workspace-name backend +``` + +These are two full-scan uploads, two server-side graphs, and two repository head/baseline sequences. In CI they can run as separate matrix jobs. See [GitHub Actions: scan changed monorepo workspaces independently](ci-cd.md#github-actions-scan-changed-monorepo-workspaces-independently). + **Generate GitLab Security Dashboard report:** ```bash socketcli --enable-gitlab-security \ @@ -138,6 +200,7 @@ This will simultaneously generate: - Both `--sub-path` and `--workspace-name` must be specified together - `--sub-path` can be used multiple times to include multiple directories +- Repeated `--sub-path` values are combined into one scan; they do not create independent workspace scans - All specified sub-paths must exist within the target-path ## Usage @@ -372,7 +435,7 @@ The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variab | `--strict-blocking` | False | False | Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode. See [Strict Blocking Mode](#strict-blocking-mode) for details. | | `--enable-diff` | False | False | Enable diff mode even when using `--integration api` (forces diff mode without SCM integration) | | `--scm` | False | api | Source control management type | -| `--timeout` | False | | Timeout in seconds for API requests | +| `--timeout` | False | 1200 | Timeout in seconds for each API request. This is not a total CLI runtime limit and does not limit local discovery, Git, or reachability analysis. | #### Plugins diff --git a/pyproject.toml b/pyproject.toml index c5e4da96..29a9e412 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.7.2" +version = "2.8.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 9a275469..a97b4d18 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.7.2' +__version__ = '2.8.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index a5305bee..b1b1d65b 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1154,6 +1154,30 @@ def create_full_scan(self, files: List[str], params: FullScanParams, base_paths: return full_scan + @staticmethod + def _log_scan_configuration( + paths: List[str], + params: FullScanParams, + files: List[str], + manifest_source: str, + base_paths: Optional[List[str]] = None, + ) -> None: + """Log aggregate scan inputs without exposing submitted manifest paths.""" + base_path = base_paths[0] if base_paths else (paths[0] if paths else ".") + absolute_base_path = os.path.abspath(base_path) + relative_roots = [ + os.path.relpath(os.path.abspath(path), absolute_base_path).replace("\\", "/") + for path in paths + ] or ["."] + log.info( + "Scan configuration: " + f"repo={json.dumps(getattr(params, 'repo', None), default=str)} " + f"workspace={json.dumps(getattr(params, 'workspace', None), default=str)} " + f"scan_type={json.dumps(getattr(params, 'scan_type', None), default=str)} " + f"roots={json.dumps(relative_roots, separators=(',', ':'))} " + f"manifests={len(files)} manifest_source={manifest_source}" + ) + def create_full_scan_with_report_url( self, paths: List[str], @@ -1196,6 +1220,14 @@ def create_full_scan_with_report_url( for path in paths: files = self.find_files(path) all_files.extend(files) + + self._log_scan_configuration( + paths, + params, + all_files, + manifest_source="provided" if explicit_files is not None else "discovered", + base_paths=base_paths, + ) # Save submitted files list if requested if save_files_list_path and all_files: @@ -1503,7 +1535,10 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: has no head scan yet (caller creates an empty baseline scan). """ if self.cli_config and self.cli_config.base_scan_id: - log.info(f"Using full scan {self.cli_config.base_scan_id} as diff baseline (--base-scan-id)") + log.info( + "Baseline selected: source=explicit-scan " + f"scan_id={json.dumps(self.cli_config.base_scan_id)}" + ) return self.cli_config.base_scan_id if self.cli_config and self.cli_config.base_commit_sha: @@ -1524,13 +1559,22 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: if self.cli_config.disable_blocking: sys.exit(0) sys.exit(self.cli_config.exit_code_on_api_error) - log.info(f"Using full scan {scan_id} (commit {commit_sha}) as diff baseline (--base-commit-sha)") + log.info( + "Baseline selected: source=explicit-commit " + f"scan_id={json.dumps(scan_id)} commit={json.dumps(commit_sha)}" + ) return scan_id try: - return self.get_head_scan_for_repo(params.repo) + scan_id = self.get_head_scan_for_repo(params.repo) except APIResourceNotFound: return None + if scan_id: + log.info( + "Baseline selected: source=repository-head " + f"scan_id={json.dumps(scan_id)}" + ) + return scan_id @staticmethod def update_package_values(pkg: Package) -> Package: @@ -1830,6 +1874,10 @@ def get_added_and_removed_packages( f"Diff scan comparison failed with {type(error).__name__}({error_summary}), " "falling back to the streaming scan comparison" ) + log.info( + "Diff comparison mode: requested=diff-scan effective=streaming " + f"reason={type(error).__name__}" + ) if diff_artifacts is None: try: @@ -1844,9 +1892,8 @@ def get_added_and_removed_packages( ) 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) + # API failures are mapped to the configured infrastructure exit code by cli(). + raise except Exception as e: import traceback log.error(f"Error getting diff report: {str(e)}") @@ -1959,6 +2006,14 @@ def create_new_diff( for path in paths: files = self.find_files(path) all_files.extend(files) + + self._log_scan_configuration( + paths, + params, + all_files, + manifest_source="provided" if explicit_files is not None else "discovered", + base_paths=base_paths, + ) # Save submitted files list if requested if save_files_list_path and all_files: @@ -1994,7 +2049,10 @@ def create_new_diff( try: head_full_scan = self.create_full_scan(empty_files, tmp_params, base_paths=base_paths) head_full_scan_id = head_full_scan.id - log.debug(f"Created empty baseline scan: {head_full_scan_id}") + log.info( + "Baseline selected: source=empty " + f"scan_id={json.dumps(head_full_scan_id)}" + ) # Clean up the temporary empty file for temp_file in empty_files: @@ -2030,9 +2088,8 @@ def create_new_diff( os.unlink(temp_file) except OSError: pass - if self.cli_config and self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(1) + # API failures are mapped to the configured infrastructure exit code by cli(). + raise except Exception as e: import traceback log.error(f"Error creating new full scan: {str(e)}") diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 1179e915..9dd3bb1e 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -60,6 +60,12 @@ def _emit_infrastructure_error(message: str, include_traceback: bool = False) -> traceback.print_exc() +def _log_scan_mode_fallback(requested: str, effective: str, reason: str) -> None: + log.info( + f"Scan mode: requested={requested} effective={effective} reason={reason}" + ) + + def build_license_artifact_payload( diff: Diff, legal_format: str = "socket", @@ -851,6 +857,11 @@ def _is_unprocessed(c): # User requested diff mode but no manifest files were detected - this should not happen with new logic # but keeping as a safety net log.warning("--enable-diff was specified but no supported manifest files were detected in the changed files. Falling back to full scan mode.") + _log_scan_mode_fallback( + "diff", + "full", + "no-supported-manifest-in-changed-files", + ) log.info("Creating Socket Report (full scan)") serializable_params = { key: value if isinstance(value, (int, float, str, list, dict, bool, type(None))) else str(value) @@ -872,6 +883,11 @@ def _is_unprocessed(c): else: if force_api_mode: + _log_scan_mode_fallback( + "default", + "full", + "no-supported-manifest-in-changed-files", + ) log.info( "No supported manifest detected in the changed-file set; " "creating a full Socket report" diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index da0efc62..d79f62f3 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -306,3 +306,35 @@ def test_empty_alerts_preserved(core): # Check the final package assert head_scan.packages["dp2"].alerts == [] # Should still be empty list + + +def test_repository_head_baseline_log(core, caplog): + with caplog.at_level("INFO", logger="socketdev"): + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + + assert 'Baseline selected: source=repository-head scan_id="head"' in caplog.messages + + +def test_explicit_scan_baseline_log(core, caplog): + core.cli_config = make_cli_config("--base-scan-id", "explicit-base") + + with caplog.at_level("INFO", logger="socketdev"): + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "explicit-base" + + assert 'Baseline selected: source=explicit-scan scan_id="explicit-base"' in caplog.messages + + +def test_explicit_commit_baseline_log(core, caplog): + core.cli_config = make_cli_config("--base-commit-sha", "abc123") + core.sdk.fullscans.get.return_value = { + "results": [{"id": "merge-base-scan"}], + "nextPage": None, + } + + with caplog.at_level("INFO", logger="socketdev"): + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "merge-base-scan" + + assert ( + 'Baseline selected: source=explicit-commit scan_id="merge-base-scan" ' + 'commit="abc123"' + ) in caplog.messages diff --git a/tests/unit/test_scan_scope.py b/tests/unit/test_scan_scope.py new file mode 100644 index 00000000..bedd2c97 --- /dev/null +++ b/tests/unit/test_scan_scope.py @@ -0,0 +1,140 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest +from socketdev.exceptions import APIFailure +from socketdev.fullscans import FullScanParams + +from socketsecurity.core import Core +from socketsecurity.core.classes import Diff + + +def _core() -> Core: + core = Core.__new__(Core) + core.config = SimpleNamespace(org_slug="example") + core.cli_config = SimpleNamespace( + disable_blocking=False, + exit_code_on_api_error=0, + generate_license=False, + ) + core.sdk = MagicMock() + return core + + +def test_multiple_scan_paths_are_uploaded_as_one_combined_full_scan(caplog): + """Repeated --sub-path roots feed one graph, not independent scans.""" + core = _core() + core.find_files = MagicMock( + side_effect=[ + ["/repo/frontend/package.json"], + ["/repo/backend/requirements.txt"], + ] + ) + core.resolve_base_full_scan_id = MagicMock(return_value="base-scan") + core.create_full_scan = MagicMock(return_value=SimpleNamespace(id="new-scan")) + core.get_added_and_removed_packages = MagicMock(return_value=({}, {}, {})) + core.create_diff_report = MagicMock(return_value=Diff()) + params = FullScanParams(repo="repo-combined", branch="feature", scan_type="socket") + params.include_license_details = True + + with caplog.at_level("INFO", logger="socketdev"): + result = core.create_new_diff( + ["/repo/frontend", "/repo/backend"], + params, + base_paths=["/repo"], + ) + + assert core.find_files.call_args_list == [ + (("/repo/frontend",),), + (("/repo/backend",),), + ] + core.create_full_scan.assert_called_once_with( + ["/repo/frontend/package.json", "/repo/backend/requirements.txt"], + params, + base_paths=["/repo"], + ) + assert result.id == "new-scan" + assert ( + 'Scan configuration: repo="repo-combined" workspace=null ' + 'scan_type="socket" roots=["frontend","backend"] manifests=2 ' + "manifest_source=discovered" + ) in caplog.messages + + +def test_scan_configuration_omits_absolute_and_manifest_paths(caplog): + params = FullScanParams( + repo="repo-service", + branch="feature", + workspace="engineering", + ) + + with caplog.at_level("INFO", logger="socketdev"): + Core._log_scan_configuration( + ["/private/build/repo/service"], + params, + ["/private/build/repo/service/requirements.txt"], + manifest_source="provided", + base_paths=["/private/build/repo"], + ) + + message = caplog.messages[-1] + assert 'workspace="engineering"' in message + assert 'roots=["service"]' in message + assert "manifests=1 manifest_source=provided" in message + assert "/private/build" not in message + assert "requirements.txt" not in message + + +def test_empty_baseline_logs_created_scan_id(caplog): + core = _core() + core.resolve_base_full_scan_id = MagicMock(return_value=None) + core.create_full_scan = MagicMock( + side_effect=[ + SimpleNamespace(id="empty-base"), + SimpleNamespace(id="new-scan"), + ] + ) + core.get_added_and_removed_packages = MagicMock(return_value=({}, {}, {})) + core.create_diff_report = MagicMock(return_value=Diff()) + params = FullScanParams(repo="repo-service", branch="feature") + params.include_license_details = True + + with caplog.at_level("INFO", logger="socketdev"): + core.create_new_diff( + ["/repo/service"], + params, + base_paths=["/repo"], + explicit_files=["/repo/service/requirements.txt"], + ) + + assert 'Baseline selected: source=empty scan_id="empty-base"' in caplog.messages + + +def test_full_scan_api_failure_propagates_for_cli_exit_code_mapping(): + core = _core() + core.resolve_base_full_scan_id = MagicMock(return_value="base-scan") + core.create_full_scan = MagicMock(side_effect=APIFailure("upload failed")) + params = FullScanParams(repo="repo", branch="feature") + params.include_license_details = True + + with pytest.raises(APIFailure, match="upload failed"): + core.create_new_diff( + ["/repo/workspace"], + params, + explicit_files=["/repo/workspace/package.json"], + ) + + +def test_diff_api_failure_propagates_for_cli_exit_code_mapping(caplog): + core = _core() + core.get_diff_scan_artifacts = MagicMock(side_effect=RuntimeError("poll failed")) + core.sdk.fullscans.stream_diff.side_effect = APIFailure("comparison failed") + + with caplog.at_level("INFO", logger="socketdev"): + with pytest.raises(APIFailure, match="comparison failed"): + core.get_added_and_removed_packages("base-scan", "new-scan") + + assert ( + "Diff comparison mode: requested=diff-scan effective=streaming " + "reason=RuntimeError" + ) in caplog.messages diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index 68a63ec7..d8f661aa 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -100,6 +100,20 @@ def test_emit_infra_error_traceback_gated(monkeypatch, capsys): assert "Traceback" in err and "ValueError: boom" in err +def test_scan_mode_fallback_log_is_structured(caplog): + with caplog.at_level("INFO", logger="socketcli"): + socketcli._log_scan_mode_fallback( + "diff", + "full", + "no-supported-manifest-in-changed-files", + ) + + assert ( + "Scan mode: requested=diff effective=full " + "reason=no-supported-manifest-in-changed-files" + ) in caplog.messages + + def test_build_license_artifact_payload_without_packages_returns_empty_dict(): diff = Diff() diff --git a/uv.lock b/uv.lock index 80dd5713..2eab7d99 100644 --- a/uv.lock +++ b/uv.lock @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.7.2" +version = "2.8.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 860b8eac976cced1b0ff6161d2e9bebdd149bf06 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Wed, 9 Sep 2026 19:40:38 -0400 Subject: [PATCH 12/21] Normalize Slack issue severity and bump SDK to `v3.6.0` (#331) * fix(slack): normalize the API's "middle" severity to "medium" Every severity lookup in the Slack reachability formatter is keyed on "medium", but "middle" is what the API sends. A mid-severity finding missed all of them at once: uncounted in the summary, excluded from total_findings so the "and N more" count can go negative, and sorted at the default order of 4 -- below "low" -- so it was truncated out of the message first. Normalized at the point the alert is read rather than by adding a parallel key to each dict, so one canonical spelling flows downstream. The GitLab severity map and the PR comment path already accept both forms; this formatter did not. Co-Authored-By: Claude Opus 5 (1M context) * chore(deps): bump socketdev to 3.6.0 Unblocks the pin now that 3.6.0 is on PyPI. SocketPURL_Type gained ten members -- alpm, chrome, clawhub, edge-extension, firefox-extension, qpkg, socket, swid, vscode and vscode-extension -- and removed none, so artifacts of those types stop falling back to "unknown". No other CLI change is needed: none of the SDK's enum types are imported here, and every severity and type lookup already has a default, so the new members cannot reach an unguarded branch. Co-Authored-By: Claude Opus 5 (1M context) * ci: only floor the version check at the latest published release The check required the PR version to exceed both main and PyPI. Comparing against main forbids the legitimate case where several PRs ship under one unreleased version: the first bumps main, and the rest ride it without bumping again so they stay under a single changelog header. Every such PR failed, and the only way to green it was a throwaway bump that would strand a changelog header on a version that never ships. PyPI is now the floor, since the real invariant is that a release cannot reuse a published version. Main is still a floor in the one direction that matters: a PR may leave the version alone or move it forwards, never back. Every genuine failure the old check caught -- forgetting to bump, reusing a published version, branching from a stale base -- still fails. Also added this workflow to its own paths filter so a change to the check is exercised by the PR that makes it. Co-Authored-By: Claude Opus 5 (1M context) * ci: require pyproject.toml and __init__.py versions to agree The version lives as two hand-maintained literals with nothing deriving one from the other: pyproject.toml is what gets published, and __init__.py is what the CLI reports as its User-Agent. Every comparison in this job read only __init__.py, so bumping that alone passed the check and then published under the old number -- surfacing late, as twine rejecting an existing file, after the merge. Both are now required to match before any other comparison runs. uv.lock carries a third copy, but uv derives it and `uv lock --locked` in python-tests already fails when it drifts, so it needs no check here. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Opus 5 (1M context) --- .github/workflows/version-check.yml | 59 +++++++++++--- CHANGELOG.md | 21 +++++ pyproject.toml | 2 +- socketsecurity/plugins/formatters/slack.py | 6 ++ .../unit/test_slack_severity_normalization.py | 76 +++++++++++++++++++ uv.lock | 8 +- 6 files changed, 158 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_slack_severity_normalization.py diff --git a/.github/workflows/version-check.yml b/.github/workflows/version-check.yml index d2ff77ad..292e2980 100644 --- a/.github/workflows/version-check.yml +++ b/.github/workflows/version-check.yml @@ -6,6 +6,8 @@ on: - 'socketsecurity/**' - 'pyproject.toml' - 'uv.lock' + # Included so a change to the check itself is exercised by its own PR. + - '.github/workflows/version-check.yml' permissions: contents: read @@ -42,16 +44,37 @@ jobs: export PR_VERSION export MAIN_VERSION - # Compare against both main and latest published PyPI release. + # Compare against the latest published PyPI release. python3 <<'PY' import json import os + import tomllib import urllib.request from packaging import version pr_ver = version.parse(os.environ["PR_VERSION"]) main_ver = version.parse(os.environ["MAIN_VERSION"]) + with open("pyproject.toml", "rb") as fh: + pyproject_ver = version.parse(tomllib.load(fh)["project"]["version"]) + + # The version is two hand-maintained literals with nothing deriving one + # from the other: pyproject.toml is what actually gets published, and + # socketsecurity/__init__.py is what the CLI reports as its User-Agent. + # Every comparison below reads only __init__.py, so bumping that alone + # would pass this job and then publish under the old number -- caught + # late, by twine rejecting an existing file, after the merge. Require + # the two to agree before comparing anything. (uv.lock carries a third + # copy, but uv derives it and `uv lock --locked` in python-tests + # already fails when it drifts.) + if pr_ver != pyproject_ver: + print( + f"โŒ Version mismatch inside the PR: pyproject.toml is " + f"{pyproject_ver}, socketsecurity/__init__.py is {pr_ver}. " + f"Bump both." + ) + raise SystemExit(1) + with urllib.request.urlopen("https://pypi.org/pypi/socketsecurity/json") as response: pypi_data = json.load(response) @@ -62,19 +85,37 @@ jobs: published_versions.append(parsed) pypi_ver = max(published_versions) if published_versions else version.parse("0.0.0") - required_floor = max(main_ver, pypi_ver) - if pr_ver <= required_floor: + # The only hard requirement is that the version is ahead of what is + # actually released. Treating main's version as a second floor breaks + # the legitimate case where several PRs share one unreleased release: + # the first bumps main to the new version and the rest ride it without + # bumping again, which is what keeps them under a single changelog + # header. Main is therefore only a floor when this PR moves the + # version -- a change to it must go forwards, never backwards. + if pr_ver <= pypi_ver: print( - f"โŒ Version must be greater than main and PyPI! " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + f"โŒ Version {pr_ver} is already published on PyPI " + f"(latest release: {pypi_ver}). Bump it." + ) + raise SystemExit(1) + + if pr_ver < main_ver: + print( + f"โŒ Version moves backwards: main is {main_ver}, PR is {pr_ver}." ) raise SystemExit(1) - print( - f"โœ… Version properly incremented. " - f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" - ) + if pr_ver == main_ver: + print( + f"โœ… Riding main's unreleased {pr_ver} " + f"(latest PyPI release: {pypi_ver})." + ) + else: + print( + f"โœ… Version properly incremented. " + f"Main: {main_ver}, PyPI: {pypi_ver}, PR: {pr_ver}" + ) PY - name: Require uv.lock update when pyproject changes diff --git a/CHANGELOG.md b/CHANGELOG.md index 29dda5bc..ba943fa8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,32 @@ - Clarified monorepo scan scoping, workspace flags, CI path filters, and timeout behavior, with a changed-workspace GitHub Actions example. +### Changed: bump socketdev to 3.6.0 + +- Bumped the pinned SDK (`socketdev`) from `3.5.0` to `3.6.0`. Its package-type + enum gained ten members โ€” `alpm`, `chrome`, `clawhub`, `edge-extension`, + `firefox-extension`, `qpkg`, `socket`, `swid`, `vscode` and + `vscode-extension` โ€” so artifacts of those types are now reported under their + own type instead of falling back to `unknown`. + ### Fixed: apply configured exit codes to API failures - Full-scan and streamed-diff API failures now use the configured infrastructure error exit code instead of the security-finding exit code. +### Fixed: mid-severity findings were dropped from the Slack summary + +- The Slack reachability formatter keyed every severity lookup on `medium`, + but the API sends `middle`. A mid-severity finding therefore missed all of + them at once: it was not counted, so the summary always read `Medium: 0`; it + was excluded from `total_findings`, which can drive the "and N more" count + negative; and it sorted at the default order of 4, below `low`, so it was the + first thing truncated when the Slack block limit was reached. +- Severity is now normalized to one spelling when an alert is read, matching + how the GitLab and PR-comment paths already handle both forms. The findings + themselves were always listed; only the counts, ordering and truncation were + wrong. + ## 2.7.2 ### Changed: bump pinned @coana-tech/cli to 15.10.39 diff --git a/pyproject.toml b/pyproject.toml index 29a9e412..a9035118 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,7 @@ dependencies = [ "GitPython==3.1.59", "packaging==26.3", "python-dotenv==1.2.3", - "socketdev==3.5.0", + "socketdev==3.6.0", "beautifulsoup4==4.15.0", "markdown==3.10.3", "brotli==1.2.0; platform_python_implementation == 'CPython'", diff --git a/socketsecurity/plugins/formatters/slack.py b/socketsecurity/plugins/formatters/slack.py index 1a3ab874..955b330a 100644 --- a/socketsecurity/plugins/formatters/slack.py +++ b/socketsecurity/plugins/formatters/slack.py @@ -108,6 +108,12 @@ def _extract_alert_info(component: Dict[str, Any], alert: Dict[str, Any]) -> Dic """ props = alert.get('props', {}) or {} severity = str(alert.get('severity') or props.get('severity') or '').lower() + # The API's mid-level severity is "middle"; every lookup in this module is + # keyed on "medium". Normalizing here rather than adding a parallel key to + # each dict keeps one canonical spelling downstream, matching what + # Messages.map_socket_severity_to_gitlab already does. + if severity == 'middle': + severity = 'medium' return { 'cve_id': str(props.get('ghsaId') or props.get('cveId') or alert.get('title') or 'Unknown'), diff --git a/tests/unit/test_slack_severity_normalization.py b/tests/unit/test_slack_severity_normalization.py new file mode 100644 index 00000000..861fe34c --- /dev/null +++ b/tests/unit/test_slack_severity_normalization.py @@ -0,0 +1,76 @@ +"""The Slack formatter keys on "medium"; the API sends "middle". + +Every severity lookup in ``socketsecurity/plugins/formatters/slack.py`` is keyed +on ``medium``, but ``middle`` is what the API actually emits -- it is the value +in the OpenAPI spec's ``SocketIssueSeverity`` and in the SDK enum. Unnormalized, +a mid-severity finding fell through every one of them at once: + +* it was not counted, so the summary always read ``Medium: 0`` +* it was excluded from ``total_findings``, which can drive ``omitted_count`` + negative when mid-severity findings are the ones being displayed +* it sorted at the default order of 4, below ``low``, so it was truncated out of + the message first when the block limit was reached + +Two other call sites already handle both spellings (``Messages.map_socket_ +severity_to_gitlab`` and the GitLab severity map); this formatter did not. +""" + +import unittest + +from socketsecurity.plugins.formatters.slack import ( + SEVERITY_EMOJI, + SEVERITY_ORDER, + _extract_alert_info, + format_socket_facts_for_slack, +) + + +def _component(severity: str) -> dict: + return { + "name": "example-package", + "version": "1.0.0", + "alerts": [{"title": "Example alert", "severity": severity, "props": {}}], + } + + +class TestSeverityNormalization(unittest.TestCase): + def test_middle_normalizes_to_medium(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity"], "medium") + + def test_middle_gets_the_medium_order_not_the_default(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity_order"], SEVERITY_ORDER["medium"]) + # Regression: the default of 4 sorted mid-severity below "low". + self.assertLess(info["severity_order"], SEVERITY_ORDER["low"]) + + def test_middle_gets_the_medium_emoji_not_the_fallback(self): + info = _extract_alert_info(_component("middle"), {"severity": "middle"}) + self.assertEqual(info["severity_emoji"], SEVERITY_EMOJI["medium"]) + self.assertNotEqual(info["severity_emoji"], SEVERITY_EMOJI["low"]) + + def test_medium_still_works(self): + info = _extract_alert_info(_component("medium"), {"severity": "medium"}) + self.assertEqual(info["severity"], "medium") + self.assertEqual(info["severity_order"], SEVERITY_ORDER["medium"]) + + def test_middle_findings_are_counted_in_the_summary(self): + result = format_socket_facts_for_slack([_component("middle")]) + self.assertEqual(len(result), 1) + self.assertIn("๐ŸŸก Medium: 1", result[0]["summary"]) + + def test_middle_findings_reach_total_findings(self): + # Regression: excluded from the total, omitted_count could go negative. + result = format_socket_facts_for_slack([_component("middle")]) + self.assertEqual(result[0]["total_findings"], 1) + + def test_unrecognized_severity_still_falls_back(self): + info = _extract_alert_info( + _component("brand-new-level"), {"severity": "brand-new-level"} + ) + self.assertEqual(info["severity_order"], 4) + self.assertEqual(info["severity_emoji"], "โšช") + + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 2eab7d99..28dea3ad 100644 --- a/uv.lock +++ b/uv.lock @@ -1280,15 +1280,15 @@ wheels = [ [[package]] name = "socketdev" -version = "3.5.0" +version = "3.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/24/0c11290dc7d59e24b7075035c7e1a3ab87fa17a445cebc88cfa6ee98b22c/socketdev-3.5.0.tar.gz", hash = "sha256:a2b20f9b98f73c25f3d2e97a1ae730504509c91219c0b393f28a9230266b3531", size = 195138, upload-time = "2026-08-06T03:47:14.185Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4f/64/7c44c1b1f739db9c40ea0a3d429d27b1810f43153b0680ec4bfc3aada644/socketdev-3.6.0.tar.gz", hash = "sha256:8453da37520db79735479e0892b74cef8ce5bcba52c99f15ffacc6a70f9ea355", size = 201023, upload-time = "2026-09-09T22:44:01.761Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/75/5a8506a473716740e94f2f01b697909333f966c143d8a0a566d278e6118d/socketdev-3.5.0-py3-none-any.whl", hash = "sha256:780f5841770397035ff87de6181d954b6318cd0a07f6fdd304d1376667f33f68", size = 72027, upload-time = "2026-08-06T03:47:12.773Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c3/e4ac2746b803109eab1fa7b44b02697f365fb418340411df5091e50d4a07/socketdev-3.6.0-py3-none-any.whl", hash = "sha256:fe017aefa72638375bb0fb1697bf5704f64143a5fab634534dfc3e77c851b374", size = 73352, upload-time = "2026-09-09T22:44:00.167Z" }, ] [[package]] @@ -1350,7 +1350,7 @@ requires-dist = [ { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, - { name = "socketdev", specifier = "==3.5.0" }, + { name = "socketdev", specifier = "==3.6.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, ] From 3070e43320c235290c778042f00af2de8730270c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:04:48 -0400 Subject: [PATCH 13/21] chore(deps): bump the python-minor-patch group with 2 updates (#351) Bumps the python-minor-patch group with 2 updates: [ruff](https://github.com/astral-sh/ruff) and [uv](https://github.com/astral-sh/uv). Updates `ruff` from 0.16.5 to 0.16.6 - [Release notes](https://github.com/astral-sh/ruff/releases) - [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/ruff/compare/0.16.5...0.16.6) Updates `uv` from 0.12.8 to 0.12.9 - [Release notes](https://github.com/astral-sh/uv/releases) - [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md) - [Commits](https://github.com/astral-sh/uv/compare/0.12.8...0.12.9) --- updated-dependencies: - dependency-name: ruff dependency-version: 0.16.6 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch - dependency-name: uv dependency-version: 0.12.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: python-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- pyproject.toml | 4 +-- uv.lock | 90 +++++++++++++++++++++++++------------------------- 2 files changed, 47 insertions(+), 47 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index a9035118..da39c80d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -47,9 +47,9 @@ test = [ "pytest-watch==4.2.0" ] dev = [ - "ruff==0.16.5", + "ruff==0.16.6", "twine==7.0.0", # for building - "uv==0.12.8", # for dependency management + "uv==0.12.9", # for dependency management "pre-commit==4.6.2", "hatch==1.18.0" ] diff --git a/uv.lock b/uv.lock index 28dea3ad..115542a2 100644 --- a/uv.lock +++ b/uv.lock @@ -1224,27 +1224,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/85/c8e12473c93018f92d19dd988a294202e1c27426c47ec4de53ffb847b8d8/ruff-0.16.5.tar.gz", hash = "sha256:1b88500f9ffbcab3dedb0082c9f9492e91ec3d618aac1236a3e0189938f7040b", size = 4912003, upload-time = "2026-08-27T16:34:18.258Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/b6/77c90a970fe2dae17a723acbd011043ea97c98d7deacccefdc4ba74ec512/ruff-0.16.5-py3-none-linux_armv6l.whl", hash = "sha256:12e5f673e774c35fbb62f288809c7653b73445f8ecec6b6063fd6ea3521aa14b", size = 10011941, upload-time = "2026-08-27T16:33:41.287Z" }, - { url = "https://files.pythonhosted.org/packages/4b/46/6cf67cf6411885a1d6f7f6d801682f155536a85176d10b605e2ceffed8bd/ruff-0.16.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:eda58a5802de40e7ed5b32b64e0b32539338cc6fcd2c78f61e3ad6a0d79f51c3", size = 10204049, upload-time = "2026-08-27T16:33:44.056Z" }, - { url = "https://files.pythonhosted.org/packages/46/fd/c8720ca7a090abf0c2fef4abe8a5ef6e5127ed15196d8886ff75a2b370e2/ruff-0.16.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5ae9a7b9a8875131f40f8fe967cc86abf899779efd663cb7ce3d572d01da7eb", size = 9809037, upload-time = "2026-08-27T16:33:46.257Z" }, - { url = "https://files.pythonhosted.org/packages/43/45/a684caacdedaca180f52bacccc40bf0789d2c5a7c75f25324853e9eaedb5/ruff-0.16.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b719b0a1f4d59710d283ab2965f621684a108a9e41da622e3b23f0326cd0025", size = 9964129, upload-time = "2026-08-27T16:33:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f2/5d2bcdaca6b5b93d1b4dfc166cd2aebf7680143a1b38a28759df13a94d31/ruff-0.16.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2298f2780ed1be0c5cb1361e32ab7b1467f3cce7dabe101d2210a314f2fe42e9", size = 9821518, upload-time = "2026-08-27T16:33:50.57Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ff/011cce29accf9257d5974145b733fc653a37985ed6825413a3987cefbfe0/ruff-0.16.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:258f29035a2dd021e7861e631b227a5b3f14e50c1184c9a6a122c5f4576154d7", size = 10534835, upload-time = "2026-08-27T16:33:52.522Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5a/f0cf109bada9bba0e96c90c21c9f9251803f57225c32d293327a03c710d6/ruff-0.16.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b9a4f0432966834019c74d1b7e5c51224305d7713f3d7faf3e7451f1a3be3cde", size = 11252550, upload-time = "2026-08-27T16:33:54.521Z" }, - { url = "https://files.pythonhosted.org/packages/63/4d/1d481aaea2046c6a7ed7c291f9004c669cce3c087b6b376ed5b08271e3fe/ruff-0.16.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b5eb3a8c3d0ade9cea42b591fd530368e8798380e30e0a308b85a5cf718f09ea", size = 10777949, upload-time = "2026-08-27T16:33:56.88Z" }, - { url = "https://files.pythonhosted.org/packages/ee/34/ee245ca55f64443233034b3d02b03236b19242004281247c079390b7facd/ruff-0.16.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef0f69e191a13a3c9816f63163c88790cb12cd157bbbb384e9c44745702ab105", size = 10311656, upload-time = "2026-08-27T16:33:59.12Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/c33a333e341c0a2b96c715b52d89a606f5a34cd4ac493cd9b8d0187186b8/ruff-0.16.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0eeab41fbea2c42f98dfb9822cdccda9d24ba38d49f6dc945b5c236d48f0ef29", size = 10532125, upload-time = "2026-08-27T16:34:01.166Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/a64cef78b40192497bb98a27a8aa8f2c98ee9ee15bc97f7712d94ef32937/ruff-0.16.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f0768e9df4300713fff30733c87575f68b6f1d8de41184e505b7fdd9c0c95eaf", size = 10097648, upload-time = "2026-08-27T16:34:03.16Z" }, - { url = "https://files.pythonhosted.org/packages/cc/4e/4cdc9ed3c3e109d2f71e62572a37457298d7bc7501ec3138babb7ed32bbd/ruff-0.16.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:95cc70cdc7aa80c338de356279d2adbeb2de0f520b9ecd8aba75b94e95e02f91", size = 9829344, upload-time = "2026-08-27T16:34:05.134Z" }, - { url = "https://files.pythonhosted.org/packages/39/4a/31ed35ce31729955fc583ee0d176d6e784c1290cb0b0a75cb2134c1ab72a/ruff-0.16.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d185c8398ded1bfd91c0c2cb258346307571eccc473a8490af8c3977399c384a", size = 10277117, upload-time = "2026-08-27T16:34:07.425Z" }, - { url = "https://files.pythonhosted.org/packages/a8/a0/60356d86687b4b666d593df213f4dc3041750d024cb7bf2cfa81cfd65c2e/ruff-0.16.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fb8e3a3c4c6a784150a7ced53b015f4b253fc2bf97a610886419ead64b4756ef", size = 10711653, upload-time = "2026-08-27T16:34:09.712Z" }, - { url = "https://files.pythonhosted.org/packages/ed/20/656d67f5b25ca9bda4e02b1de25867b2954e1d19e03648060f167ad0f4cc/ruff-0.16.5-py3-none-win32.whl", hash = "sha256:288b0a5f080492fe5635db849f9e2e84aa3cce7b7f0e955997d416c507c76a26", size = 10034250, upload-time = "2026-08-27T16:34:11.8Z" }, - { url = "https://files.pythonhosted.org/packages/5b/42/ee8e68a207b9127fcde6c3d7e197def432f346cb1af159e1fa14ca0d1cdc/ruff-0.16.5-py3-none-win_amd64.whl", hash = "sha256:ddc6385fb2137f616357ca03d6c74f4be987f80fed4008566b754f6032b8546f", size = 10516714, upload-time = "2026-08-27T16:34:13.963Z" }, - { url = "https://files.pythonhosted.org/packages/73/e3/7df5a396e445b9ba49ce9a9437439a4d80042c61c0ade199abf8d16de1ac/ruff-0.16.5-py3-none-win_arm64.whl", hash = "sha256:a64abe90968719b851bb7cedffaa8753fbdbdadab483089682db623f3edc587e", size = 10391564, upload-time = "2026-08-27T16:34:16.064Z" }, +version = "0.16.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a4/7c/6adb35d70e7c027e308274557901c7e00fb3407750faf3620c184ae058cb/ruff-0.16.6.tar.gz", hash = "sha256:dcf8a73d2ff77e99dde91244b4da16feba7f14e6beeb4015dee7c5a909e99050", size = 4921251, upload-time = "2026-09-03T16:57:29.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/28/9cc1b79639e284ec103f43c88c644db4eb58cbd0ea1ca11f1193435369ac/ruff-0.16.6-py3-none-linux_armv6l.whl", hash = "sha256:61c368c26bf8e973e5ab14a2772de587bc068ea3f9a277f673380749b4898fb8", size = 10015638, upload-time = "2026-09-03T16:56:40.986Z" }, + { url = "https://files.pythonhosted.org/packages/71/11/627d342ef727ea7794edf74fe23d60a074b02c3acc2e9436684e782286ca/ruff-0.16.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ecf4f068e2e123e43a26e9db4e19524cc56563912404e83bbfca375757e45a32", size = 10220762, upload-time = "2026-09-03T16:56:44.681Z" }, + { url = "https://files.pythonhosted.org/packages/43/d9/b75668ce41e4c8d073d18d6d08672ba6906ce45d5c06ea4fdb2e84ce3853/ruff-0.16.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:99b62ea33baf130f50368798d841f0d95527b6d817bf31817b65dd058f1d314c", size = 9835082, upload-time = "2026-09-03T16:56:47.142Z" }, + { url = "https://files.pythonhosted.org/packages/99/97/123ab10b05cde889c107c20f5a9774955104b5552796a2a8584b089ae8eb/ruff-0.16.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7fbf89013f2bb3f6835a6038ff658dc8a1b38c98dc8e724b964168ad4e881876", size = 9949304, upload-time = "2026-09-03T16:56:49.813Z" }, + { url = "https://files.pythonhosted.org/packages/3e/58/a4a2c59dd2e5b85929c912d9cac3056eb9ee8c7e75e9b9fe3e109174966b/ruff-0.16.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56a67065e22efa6bc4d498299d3bb06c0c90aace8fac2068b5a12f9dc4d8d51d", size = 9840612, upload-time = "2026-09-03T16:56:52.368Z" }, + { url = "https://files.pythonhosted.org/packages/61/6a/ff8c8626a786c4f49d48ced4a752dadbca65f5263005f9c2416578194694/ruff-0.16.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e25cc89174874b176a157e4428d66761c2c0c006654419bf384f967f361ff1b1", size = 10543465, upload-time = "2026-09-03T16:56:55.089Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bb/c47535923365f337b82e28192e4e9eef2176511007cfd99a62fc22df5dad/ruff-0.16.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0700580ed5303723cb3c11c2f1d2a8913ce77b7ea86646dddb887f5417a9ba70", size = 11267576, upload-time = "2026-09-03T16:56:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/ba/50/e5119a5212b5cd63b51e1f4b25e7bd636a6668fc069a3160b108ad7e3c16/ruff-0.16.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15f1d0b6e165a6e56567befb6629f8209271311d990bae0f37e6d065035ef5f3", size = 10781993, upload-time = "2026-09-03T16:57:00.666Z" }, + { url = "https://files.pythonhosted.org/packages/8b/98/083d8b4ef3c51a0d19db84367791cbe9f44e4b53343d19dfa83556e1cd9a/ruff-0.16.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d72c591a96986ee4268860e2b7235082129ca5e4cb9cbba653a4b57c11893757", size = 10317748, upload-time = "2026-09-03T16:57:03.428Z" }, + { url = "https://files.pythonhosted.org/packages/9a/29/68f7ff2c5ad95f19f00627ac2de95644e25fe47371ea60b2db1fd952315e/ruff-0.16.6-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:65a006baa18f33324325814c864daef03541d51564b98c517610ea756ab7003e", size = 10540096, upload-time = "2026-09-03T16:57:06.182Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f9/79a8f6de85968641d68a7863aeec577551924ef066a990a48ff93167beab/ruff-0.16.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:cd02a7bf1a21a8735228a3e8c95a9dc5cf86bd2a52194f4aaae2a5755b4de0f4", size = 10100494, upload-time = "2026-09-03T16:57:09.194Z" }, + { url = "https://files.pythonhosted.org/packages/d9/e8/b81a22d9b90c00b892ccf2fa2ac36fa95de4c13ab85aea3e73795cfe4651/ruff-0.16.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:31b36f1e5ad85e0737f09d2be4e512e2e283583c14015da3b9dc07359ac0fc88", size = 9843663, upload-time = "2026-09-03T16:57:12.168Z" }, + { url = "https://files.pythonhosted.org/packages/39/aa/54f516ec5e5a11c4afdceb1c454ebb054ffb96e4f4a1705580b4346abd35/ruff-0.16.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:61029b4ab4aa723fd3064fab96b1d814492596bf0c792679fffcbde1e1679953", size = 10282461, upload-time = "2026-09-03T16:57:15.077Z" }, + { url = "https://files.pythonhosted.org/packages/52/0b/38d0aa8aa32372b96dc44f97b22e576c4147808271aab7b2cb1e353d4445/ruff-0.16.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9ac8998457832c2061709d900856b7ad271dace0cb41f346588d540162bfa718", size = 10728808, upload-time = "2026-09-03T16:57:17.797Z" }, + { url = "https://files.pythonhosted.org/packages/5e/e5/9e274e24eeb027640ffc7442f21239f16d17f47acec15ae34f32e03a5c79/ruff-0.16.6-py3-none-win32.whl", hash = "sha256:0b87d9d16fcb63e8018423ca1d50b7260f15cb2da33e30db4baad4183a948c25", size = 10049212, upload-time = "2026-09-03T16:57:20.55Z" }, + { url = "https://files.pythonhosted.org/packages/22/31/72472449414223ed1a2da236b992adbb1a2ae59e34794574810f60ce068e/ruff-0.16.6-py3-none-win_amd64.whl", hash = "sha256:10d21c51c3495d8eaea7b703a16592117ea6eb1d649e36335aa965ff1173eb39", size = 10556402, upload-time = "2026-09-03T16:57:23.501Z" }, + { url = "https://files.pythonhosted.org/packages/fc/07/d781f8f8e1ac24bef9f3269cf62ffb1407ca24c3a8f12e5e22874f90528c/ruff-0.16.6-py3-none-win_arm64.whl", hash = "sha256:7a976c79b958f94e50a022a19f0f8c87387448020935ec14fc74331bd0a7f2c5", size = 10412850, upload-time = "2026-09-03T16:57:26.416Z" }, ] [[package]] @@ -1349,10 +1349,10 @@ requires-dist = [ { name = "pytest-watch", marker = "extra == 'test'", specifier = "==4.2.0" }, { name = "python-dotenv", specifier = "==1.2.3" }, { name = "requests", specifier = "==2.34.2" }, - { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.5" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.16.6" }, { name = "socketdev", specifier = "==3.6.0" }, { name = "twine", marker = "extra == 'dev'", specifier = "==7.0.0" }, - { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.8" }, + { name = "uv", marker = "extra == 'dev'", specifier = "==0.12.9" }, ] provides-extras = ["test", "dev"] @@ -1505,28 +1505,28 @@ wheels = [ [[package]] name = "uv" -version = "0.12.8" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b1/38/53754ae5033d0dbfe01976249e4b1b48539fe5080aeec162bc920f6244d6/uv-0.12.8.tar.gz", hash = "sha256:dc6a191265fed5102d9678958b3a157c81e61246316f07e7d980c3616032f49d", size = 7124914, upload-time = "2026-08-31T22:18:18.1Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/27/13/8e6984272962a273323c58113ae390eb709f7a74726009e1bcecbb7fe0fd/uv-0.12.8-py3-none-linux_armv6l.whl", hash = "sha256:e6c617d5674867653738eaec333025142e9de33410955f8f6ae40e2e8ac85e77", size = 22245394, upload-time = "2026-08-31T22:17:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/0d/38/18d7ae5d4b40714b8e2c474bfcac907a8dd27573044b60e808b895a07423/uv-0.12.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:edfb42bc830b4c473653ed5470ac4d967ae2cd40c1b7409ec4c7c8b655d04235", size = 20544653, upload-time = "2026-08-31T22:17:27.429Z" }, - { url = "https://files.pythonhosted.org/packages/84/32/249b0ec1a62916b8e7c2638154b930daaf9eae2df8b0807ad455c8916b7e/uv-0.12.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:842e27b85ec85c32369919b6dc72a4ae0c737b4376165d34a6ebb8329ce9d744", size = 17363409, upload-time = "2026-08-31T22:17:30.309Z" }, - { url = "https://files.pythonhosted.org/packages/62/4c/74ba60fbcda11be5aa5a7f139bc122834ec740d735e155ad9324aff3570a/uv-0.12.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:ff4d67a13a3283004e3b00e22c39922c27b0e1929f82f9d6873dc85dc8bf6dc7", size = 21484891, upload-time = "2026-08-31T22:17:33.144Z" }, - { url = "https://files.pythonhosted.org/packages/4a/92/6817f8f705bcf0e6b51302a55a899faf2a29d9c3f056cb04f9f133a7192b/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:a8a8129edee5591b09933b8387d3ccc250933a9cb39f98bde89d6e4f920a870d", size = 21731676, upload-time = "2026-08-31T22:17:36.323Z" }, - { url = "https://files.pythonhosted.org/packages/6f/df/7ca009329a0b2194ca709f51e1f304b01086e0f99f3f8e0ea81460a193b9/uv-0.12.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ef0c05b39d8701b4bb8196aeaf2594147d8369a1026b0b731eead41cb1df1884", size = 21749846, upload-time = "2026-08-31T22:17:39.517Z" }, - { url = "https://files.pythonhosted.org/packages/ba/46/1e73455d1c4d8f9eafaac19879ba3fc5b8c0743d1b04d11ad1ac24e1a522/uv-0.12.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ddaa3ceb3f877cce42e75084d10ebe53148163aaa4b1d16432c3b952e9c7466c", size = 22469076, upload-time = "2026-08-31T22:17:43.016Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a3/f11be2600c67345bfb694fd7c68dad97d4133d54306d298a2690b21da66b/uv-0.12.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7e9b112000bd5ff8e8c97922f03acede7d1f2385b3261bc6a15d6b18871e9a25", size = 23653340, upload-time = "2026-08-31T22:17:46.23Z" }, - { url = "https://files.pythonhosted.org/packages/3f/f9/09e1762b40451227fc944551c7ea62d13f9b29e4965bf23adffeaba0f29a/uv-0.12.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1a2f848916a6e5b3111e5d4c9e6ac66d9e38c561da4fa3a4ff51691e3c55974c", size = 23346672, upload-time = "2026-08-31T22:17:49.145Z" }, - { url = "https://files.pythonhosted.org/packages/19/e7/6e1797aec65defdc02a81444c2d8964101876097c461993fa1e14641782e/uv-0.12.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9d63d046051d33b36260146df5aef03e9166b30b4546e9b6e554be152b69f9f9", size = 20068890, upload-time = "2026-08-31T22:17:52.225Z" }, - { url = "https://files.pythonhosted.org/packages/62/b5/2662d5da5158e4db444a9e050f8f1eaecea2832311e2da27ac53a5d15ebb/uv-0.12.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7db4fb5d559bab30ea2310db9119cfd44bc438c09f24cb33d6b9a518fd671143", size = 19434317, upload-time = "2026-08-31T22:17:55.008Z" }, - { url = "https://files.pythonhosted.org/packages/f8/0f/fe154c14adc7af8e659784a59f34c2c0dda6462c1427ce6ac5fff2652391/uv-0.12.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:dc5990c941a7c064f761f13ddfd9a0de66a0063676e228403e13709e0254094f", size = 22454547, upload-time = "2026-08-31T22:17:58.008Z" }, - { url = "https://files.pythonhosted.org/packages/77/d9/8e3bd5733290cd8428db0995d1b9ef633760cf12ae6cdd437eddcae95d93/uv-0.12.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5e30eb88618e8ac315b6511bcd64bf4b40aa0737912ce9c2eb988dc6aa59473b", size = 22575842, upload-time = "2026-08-31T22:18:00.959Z" }, - { url = "https://files.pythonhosted.org/packages/06/3f/c6c2331189bb92f72487d85c94d87af2b888f2fa9e95c4d3163247c2f850/uv-0.12.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:8d3bd25b02887fd437fc8b308101c89b651ac43870579bb1a498d139b096582d", size = 21585514, upload-time = "2026-08-31T22:18:03.892Z" }, - { url = "https://files.pythonhosted.org/packages/8d/fa/094b6154e4fb624b3aeeb313fb6888e439e3d65092cf5fcbb4ea1e362c17/uv-0.12.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:b8b589f36222ed73f070598d9cd68f7dc706cf5d670a8c469b648287a97a590a", size = 22850849, upload-time = "2026-08-31T22:18:06.887Z" }, - { url = "https://files.pythonhosted.org/packages/e9/aa/c0755ee526db93453b2a5b4079f46ece9819c9e26918be36187888c856c6/uv-0.12.8-py3-none-win32.whl", hash = "sha256:a9ddf59627e0718db0e84b329005462f335fe707723813036322f00133d992fa", size = 19862492, upload-time = "2026-08-31T22:18:09.864Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f2/3564ae1d1d37b66dc64e6be52f5e5fe00cfce235b6f60d0b708c6caf7588/uv-0.12.8-py3-none-win_amd64.whl", hash = "sha256:53984d68cddd227e6433b70b510d4b47fe82e658f22f4a6ca416b4e373406393", size = 18084781, upload-time = "2026-08-31T22:18:12.77Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b4/724322de5a51ec8c59e3e8d90eeaa8f7c7d0b3b58390805908b87dfd57d7/uv-0.12.8-py3-none-win_arm64.whl", hash = "sha256:71763f479286a9e3285642fba8fca710aea9596d56e72bc247ecdf882e05ebc4", size = 19526211, upload-time = "2026-08-31T22:18:15.521Z" }, +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/42/6d2be569fd280ebd982a54ee2e3d3754fedde5cf557fd7f858eb40d161cf/uv-0.12.9.tar.gz", hash = "sha256:55b8920edb2a29eeef31e246fb60d2145985e17f51ad14b26643575b82759302", size = 7134801, upload-time = "2026-09-01T21:57:47.305Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/73/4a/208c1ed443a5024bdb83cedf1e09b322e68ccc9f7e11d1f53bb1f0690174/uv-0.12.9-py3-none-linux_armv6l.whl", hash = "sha256:97062b2160a97fcfa45f10bbbe02a9c6ba4f5a50cf4e02c0f2babb80b47c1ea8", size = 22183326, upload-time = "2026-09-01T21:57:01.443Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/7cc3d45951bb01089bf948d91a89c5b433789df8ceeba4f4cbcce0b1ef5c/uv-0.12.9-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0cb7e8a732d81f25d364de8d6ac69f4d00d56b581918ac242eeaeffb53977588", size = 20481767, upload-time = "2026-09-01T21:57:04.573Z" }, + { url = "https://files.pythonhosted.org/packages/5f/2b/28f22620d336069038e850ff662be979866c4944126d17a19bed738e3dc1/uv-0.12.9-py3-none-macosx_11_0_arm64.whl", hash = "sha256:0af988f800778913d36f9bf6905eb21c917dccc403bb364ad739bfd383acf491", size = 17338955, upload-time = "2026-09-01T21:57:06.884Z" }, + { url = "https://files.pythonhosted.org/packages/cc/29/649ff920dcd2373a819a4e0d9c1989ae770698e634c277e5a6fbbe19e9b4/uv-0.12.9-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:cce6e716df290b7f4a31f76ef39866e1dc006e947520a660459dea00b5d55234", size = 21598566, upload-time = "2026-09-01T21:57:09.223Z" }, + { url = "https://files.pythonhosted.org/packages/35/0d/0db7cfdd5bf11ce91c53558449f0e753d3cc578d9e94bd350c10b43f17ef/uv-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:00ff9f34c04055ebc3e1599ee459f5ae0d42c17cd6a1d8d029f0389e64a1419c", size = 21690729, upload-time = "2026-09-01T21:57:11.866Z" }, + { url = "https://files.pythonhosted.org/packages/69/bf/a63391a9d0f930f5ec23ff6a1daa9f7806a430fe41fb1e3bfb6cb10ef737/uv-0.12.9-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d4a1d4c067f9e2edf703e181a71db8fc9d7af5fcdbd0053103d0a1260e04ce6", size = 21716103, upload-time = "2026-09-01T21:57:14.407Z" }, + { url = "https://files.pythonhosted.org/packages/f5/4c/4b1fea7c22abac3297fa7fc222f3703c33ffe45aa7553fa9d8bd2b0b70bb/uv-0.12.9-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cafa85338ce1d27b7caa4aadc83ab21f350e2cd2185462fbb10a232b6e93152e", size = 22340621, upload-time = "2026-09-01T21:57:17.109Z" }, + { url = "https://files.pythonhosted.org/packages/25/57/7ac6fd2b97ea9f608b65bb9b47d686e5d64f8142b190d3c4590d74cf8524/uv-0.12.9-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbdcb9db8b44d80e3fcebc4205604d52ea145404efb179958560bb2a0157743a", size = 23586367, upload-time = "2026-09-01T21:57:19.494Z" }, + { url = "https://files.pythonhosted.org/packages/ff/87/3ef6bf32db7af74e7c230ef6c06c387d9be2363dfa63eb524065031c010a/uv-0.12.9-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cfb512d0e284e9fc42c3bbe9b701f430083fca23dbb67a20daeea897fb793069", size = 23279627, upload-time = "2026-09-01T21:57:22.146Z" }, + { url = "https://files.pythonhosted.org/packages/37/4b/cd04809c7ad5149faac55160925bd67aab43f803f6c01be1c32aec7d24d9/uv-0.12.9-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5badfd805fd88bf99b4b4f044f6e8f762f1892cab27477f4427bb473e93dd049", size = 20056367, upload-time = "2026-09-01T21:57:24.49Z" }, + { url = "https://files.pythonhosted.org/packages/29/6b/59cddb48c47124428d329ac3ed4ce91ccd8ab757ebeed3d482115d8b82ef/uv-0.12.9-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c0f7e2b46e4c503f9497c0f41bba3570aa233685db814cd011225118f2d72b6a", size = 19383069, upload-time = "2026-09-01T21:57:27.019Z" }, + { url = "https://files.pythonhosted.org/packages/99/a6/22e4650cc9a2d018165fda5a53ca7addedc6d4f083ab7c833acc12fbe1e1/uv-0.12.9-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:445c572202243229043abaeab2fecb1b3691477c71c79feb07f638ce34febd39", size = 22392774, upload-time = "2026-09-01T21:57:29.323Z" }, + { url = "https://files.pythonhosted.org/packages/d4/37/68166b0acb7f8ff30316bf74c0e9840c7c6cbc86ee3e236382bb107d42db/uv-0.12.9-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ce4ad780d3cefe5a8f965f3ddf942c01e71aa37e36f4fc431e72e4ee8ce5f378", size = 22517532, upload-time = "2026-09-01T21:57:31.758Z" }, + { url = "https://files.pythonhosted.org/packages/28/ac/8801d60db574aa15fba53944e0ee2e298605bf9076a5389f5e56ed51206a/uv-0.12.9-py3-none-musllinux_1_1_i686.whl", hash = "sha256:f639f8415f08918fb4ba2022bfd94d039e01c6c1eca28583125d439f7bd635fb", size = 21498480, upload-time = "2026-09-01T21:57:34.402Z" }, + { url = "https://files.pythonhosted.org/packages/ec/47/663bbc05f26c6c518c086da5df1e38fc5b2650fa6768cb36c6418d3f9559/uv-0.12.9-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:5f9325495fa0bf3e71e6b09f72e68b0d4a000067888b48a659536a8c7191a112", size = 22889848, upload-time = "2026-09-01T21:57:37.284Z" }, + { url = "https://files.pythonhosted.org/packages/58/ce/a4b1ae2a80264231a4dd0a070ffd0fe7f2bdf1d78e99d3c0c68ce315262b/uv-0.12.9-py3-none-win32.whl", hash = "sha256:f4ab95475562884fc8e72fbbf0c3da0e025d3bb25e7348544cd02b19bf77f3e1", size = 19858143, upload-time = "2026-09-01T21:57:39.571Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b2/55c7fdbdacb0b110f92804cceea449dfcd5be4354ae1e997aca700145a65/uv-0.12.9-py3-none-win_amd64.whl", hash = "sha256:871e884c3329f69c12bb7426edb9e727745c3bb2db6d9e0538ce0e1bdfb64916", size = 17991226, upload-time = "2026-09-01T21:57:42.282Z" }, + { url = "https://files.pythonhosted.org/packages/85/67/cac985492192ff4a95a76b3854143062f1b3290aca3a404a812b7aadc9cf/uv-0.12.9-py3-none-win_arm64.whl", hash = "sha256:31fddf25d756a51fe33f7831ff697a122628dd3121c37c435c39c678428f937b", size = 19472343, upload-time = "2026-09-01T21:57:44.877Z" }, ] [[package]] From 962306c1d89917ffbebf2fe0c161aee6b97f7863 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:07:07 -0400 Subject: [PATCH 14/21] ci(deps): bump docker/setup-qemu-action (#350) Bumps the github-actions-minor-patch group with 1 update in the /.github/actions/setup-docker directory: [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action). Updates `docker/setup-qemu-action` from 4.2.0 to 4.3.0 - [Release notes](https://github.com/docker/setup-qemu-action/releases) - [Commits](https://github.com/docker/setup-qemu-action/compare/96fe6ef7f33517b61c61be40b68a1882f3264fb8...1f40c72289eff860ee54a304f1438e3cff362e0a) --- updated-dependencies: - dependency-name: docker/setup-qemu-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: github-actions-minor-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- .github/actions/setup-docker/action.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/actions/setup-docker/action.yml b/.github/actions/setup-docker/action.yml index 66abffdc..c5ce0fa3 100644 --- a/.github/actions/setup-docker/action.yml +++ b/.github/actions/setup-docker/action.yml @@ -19,7 +19,7 @@ inputs: runs: using: "composite" steps: - - uses: docker/setup-qemu-action@96fe6ef7f33517b61c61be40b68a1882f3264fb8 # v4.2.0 + - uses: docker/setup-qemu-action@1f40c72289eff860ee54a304f1438e3cff362e0a # v4.3.0 if: inputs.enable-qemu == 'true' - uses: docker/setup-buildx-action@37fe631027851001ddb9b187196cc803df7f5f0e # v4.3.0 - uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 From 6864479928348b4d5966ca268d54b51a154e9d8b Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 13:24:44 -0400 Subject: [PATCH 15/21] Bump pinned @coana-tech/cli to 15.10.40 (#352) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> Co-authored-by: lelia <2418071+lelia@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 18 +++++++++--------- 6 files changed, 24 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba943fa8..b6eebb26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.8.1 + +### Changed: bump pinned @coana-tech/cli to 15.10.40 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.39` to + `15.10.40`. See the + [reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog) + for engine changes. + ## 2.8.0 ### Changed: improve monorepo scan diagnostics and guidance diff --git a/docs/cli-reference.md b/docs/cli-reference.md index e20c34b2..f64de267 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -338,7 +338,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.39 | 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.40 | 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 da39c80d..33d2d4df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.0" +version = "2.8.1" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index a97b4d18..6cf31cd7 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.0' +__version__ = '2.8.1' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index f43ce5c8..3290080f 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -19,7 +19,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.39" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.40" # 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 @@ -56,7 +56,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.39'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.40'). Args: version: Coana CLI version to use. @@ -65,7 +65,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.39'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.40'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 115542a2..69bb3127 100644 --- a/uv.lock +++ b/uv.lock @@ -12,8 +12,8 @@ name = "anyio" version = "4.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "idna" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "idna", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } wheels = [ @@ -476,7 +476,7 @@ name = "cryptography" version = "50.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, + { name = "cffi", marker = "(python_full_version != '3.12.*' and platform_python_implementation != 'PyPy') or (platform_python_implementation != 'PyPy' and sys_platform != 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } wheels = [ @@ -644,8 +644,8 @@ name = "httpcore2" version = "2.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" } wheels = [ @@ -725,7 +725,7 @@ name = "importlib-metadata" version = "8.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "(python_full_version < '3.13' and sys_platform != 'emscripten') or (python_full_version < '3.12' and sys_platform == 'emscripten')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } wheels = [ @@ -1252,8 +1252,8 @@ name = "secretstorage" version = "3.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cryptography" }, - { name = "jeepney" }, + { name = "cryptography", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "jeepney", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } wheels = [ @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.8.0" +version = "2.8.1" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 2dd3001d9a5456a6b0e18107e1641989e2b969ee Mon Sep 17 00:00:00 2001 From: "socket-pr-bot[bot]" <294242679+socket-pr-bot[bot]@users.noreply.github.com> Date: Tue, 15 Sep 2026 10:35:29 +0200 Subject: [PATCH 16/21] Bump pinned @coana-tech/cli to 15.10.41 (#354) Co-authored-by: socket-pr-bot[bot] <294242679+socket-pr-bot[bot]@users.noreply.github.com> --- CHANGELOG.md | 9 +++++++++ docs/cli-reference.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/core/tools/reachability.py | 6 +++--- uv.lock | 2 +- 6 files changed, 16 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b6eebb26..9b0cf1a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## 2.8.2 + +### Changed: bump pinned @coana-tech/cli to 15.10.41 + +- Bumped the pinned reachability engine (`@coana-tech/cli`) from `15.10.40` to + `15.10.41`. See the + [reachability analysis changelog](https://docs.socket.dev/docs/reachability-analysis-changelog) + for engine changes. + ## 2.8.1 ### Changed: bump pinned @coana-tech/cli to 15.10.40 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f64de267..2cc3999e 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -338,7 +338,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.40 | 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.41 | 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 33d2d4df..5f812f5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.1" +version = "2.8.2" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 6cf31cd7..29fd46e0 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.1' +__version__ = '2.8.2' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/core/tools/reachability.py b/socketsecurity/core/tools/reachability.py index 3290080f..1c1bc43e 100644 --- a/socketsecurity/core/tools/reachability.py +++ b/socketsecurity/core/tools/reachability.py @@ -19,7 +19,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.40" +DEFAULT_COANA_CLI_VERSION: Final = "15.10.41" # 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 @@ -56,7 +56,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.40'). + Resolve the @coana-tech/cli package spec to run (e.g. '@coana-tech/cli@15.10.41'). Args: version: Coana CLI version to use. @@ -65,7 +65,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.40'). + str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.10.41'). """ return f"@coana-tech/cli@{self._resolve_coana_version(version)}" diff --git a/uv.lock b/uv.lock index 69bb3127..446a9241 100644 --- a/uv.lock +++ b/uv.lock @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.8.1" +version = "2.8.2" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 88408dcc702eb0a8d22e6e58f4c39af7668fecb1 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:32:05 -0400 Subject: [PATCH 17/21] Fix GitLab report links, identifiers and locations, and make diff baselines resilient (#337) * fix(gitlab): stabilize report data * chore: bump version to 2.8.1 * fix(gitlab): harden implicit diff baseline resolution The workspace-scoped head scan lookup treated any failed request as "no baseline". The SDK logs and returns {} for every non-200, so a transient API error resolved to None, and create_new_diff answers None by creating an empty baseline scan -- reporting every dependency in the repository as newly added. An absent "results" key now raises APIFailure, and resolve_base_full_scan_id surfaces it the same way a missing --base-commit-sha baseline is surfaced. Selecting the newest scan on the default branch also reintroduced temporary scans, which the repository head pointer had excluded. The empty baseline scan that create_new_diff creates inherits the branch and commit of the run that created it, so a default-branch run whose real scan fails leaves that empty scan as the newest one. Both baseline lookups now skip tmp scans. Also unwrap scan_type before it is URL encoded. FullScanParams types it as a ScanType enum, and urlencode renders a (str, Enum) member as its repr-style name, which would filter on a scan type that does not exist. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): match snake_case vulnerability ids in report identifiers Issue.props reaches the GitLab formatter from several sources, and core.alert_selection already matches both ghsaId/ghsa_id and cveId/cve_id when deciding reachability. The identifier extractor only read the camelCase spellings, so an alert carrying ghsa_id was selected for the report but emitted with only its socket_alert identifier -- the CVE and GHSA values GitLab dedupes and links on were dropped. Values that are neither a string nor a sequence are now skipped rather than iterated, so a malformed prop cannot raise out of the whole report. Co-Authored-By: Claude Opus 5 (1M context) * test: use a generic package name in the namespace normalization fixture The fixture named a real organization. Public test data should not, so use the reserved com.example namespace instead. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): use colon-separated Maven coordinates in package links Socket addresses Maven package pages as groupId:artifactId. The CLI emitted the slash-separated form, so every Maven package link 404'd -- the dashboard's Maven handler rejects the slash form outright with "Maven package must have a colon". Removing the enum leak from these URLs fixed how they looked without fixing where they pointed. The separator now follows the ecosystem, via Package.socket_url, which both the full-scan and diff construction paths call. Previously each built its URL inline and they disagreed on namespace handling, so the same package could produce different links depending on which path ran. Purl strings are deliberately left on the slash form for every ecosystem: that is what the purl spec defines and what Socket's purl API consumes. Only the dashboard URL is ecosystem-dependent. Co-Authored-By: Claude Opus 5 (1M context) * docs: tighten comments added by this branch The Maven separator rule was explained in three places and the enum-unwrapping rule in two. Each now has one home: the separator at URL_NAMESPACE_SEPARATORS where it is defined, the enum behavior at each helper that depends on it, stated once rather than narrated. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): warn when a Maven coordinate has no namespace An ecosystem with its own URL separator cannot be addressed without both halves of the coordinate. A Maven artifact that arrives with no groupId still gets a link so the finding reports, but that link cannot resolve, and previously it was emitted silently. It now logs a warning naming the package. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): separate namespace and name with a slash, not a colon Reverts the separator introduced two commits ago. It rested on a report that the slash form does not resolve, which has since failed to reproduce: every affected link in that report loads, and the report's own screenshots show a working slash-form link. The defect those links actually exhibit is a namespace and name fused with no separator at all, which yields one path segment that cannot be split back into two. A slash fixes that and matches what the other package construction path has always emitted. The missing-namespace warning is kept and re-aimed: an absent namespace is what produces the unsplittable single segment, so that is the case worth surfacing. Co-Authored-By: Claude Opus 5 (1M context) * chore: bump version to 2.8.2 Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): report a real manifest and real directness in report locations Two defects in the same location block. The manifest path fell back to "unknown" whenever a package had no introducing chain. That happens routinely for a transitive package whose top-level ancestors are absent from the scan's package set, which a diff-scoped run causes by construction. The package records its own manifest files regardless, so those are now used before giving up. Directness was inferred by looking for " > " in the introducing entry, but no producer emits that separator -- get_source_data yields either ("direct", files) or (ancestor_purl, files). Every finding was therefore reported as direct, including transitive ones. It now comes from the package record. The dependency chain was also parsed into a local that was never read, and the docstring advertised a dependency_path key the function never returned. Both are removed rather than wired up, since the GitLab schema expects dependency references rather than a name path. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): omit an absent identifier url instead of sending null The GitLab dependency-scanning schema types an identifier's url as a string matching ^(https?|ftp)://, so a null fails validation. The socket_alert identifier emitted null whenever an alert carried no url, which invalidates that finding for every consumer that validates the report. Verified against the published schema: a report containing an alert with no url now produces zero validation errors. Co-Authored-By: Claude Opus 5 (1M context) * feat(gitlab): fall back to the nearest scanned ancestor for --base-commit-sha A merge base can have no full scan even when default-branch scanning is configured and running: squash merges and rebases rewrite commits, and a multi-commit push produces one scan for the tip while leaving the commits in between unscanned. Any of those turned every open merge request into a failed pipeline, because a missing baseline was a hard stop with no degraded mode. The requested commit is still preferred. When it has no scan, one listing of recent scans is matched against local first-parent history and the nearest scanned ancestor is used instead, logged at warning with the commit chosen and its distance. Only an unreachable ancestor now fails the run. Both bounds are fixed and neither costs an extra request: the listing is fetched once, and the walk stops at a set depth. Following first parents keeps a merge commit from contributing everything merged into it, and a shallow checkout simply narrows the search rather than breaking it. Co-Authored-By: Claude Opus 5 (1M context) * fix: harden diff baseline resolution --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 27 +++ README.md | 17 +- docs/ci-cd.md | 22 +- docs/cli-reference.md | 16 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 6 +- socketsecurity/core/__init__.py | 319 +++++++++++++++++++++++--- socketsecurity/core/classes.py | 70 +++++- socketsecurity/core/messages.py | 104 +++++---- tests/core/test_package_and_alerts.py | 91 +++++++- tests/core/test_sdk_methods.py | 308 ++++++++++++++++++++++++- tests/unit/test_gitlab_format.py | 128 ++++++++++- uv.lock | 2 +- 14 files changed, 980 insertions(+), 134 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b0cf1a6..3b8bcdfd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 2.8.3 + +### Fixed: GitLab report serialization and workspace baselines + +- Full-scan package identities and Socket links now preserve namespaced packages + when the SDK returns enum-backed ecosystem values. +- Namespaced package links separate the namespace from the name instead of + concatenating them, so Maven links no longer fuse groupId and artifactId into a + single unresolvable path segment. A namespaced package whose namespace is + missing now logs a warning rather than emitting a broken link silently. +- GitLab dependency-scanning reports emit CVE and GHSA identifiers from current + API fields while remaining compatible with legacy CVE data. +- GitLab report findings record the manifest they came from when the package's + introducing chain is unavailable, instead of reporting the location as + `unknown`, and report whether a dependency is direct from the package record + rather than inferring it from a dependency-path string that is never produced. +- `--base-commit-sha` degrades to the nearest scanned ancestor of the requested + commit instead of failing the run, and logs which commit was used and how far + back it is. Squash merges, rebases, and multi-commit pushes all leave a merge + base unscanned even when default-branch scanning is configured correctly. The + lookup follows paginated scan history and the run still fails when no scanned + ancestor is reachable or the exact-commit lookup itself fails. +- Implicit diff baselines are selected from the same workspace, scan type, + repository, and default branch, including when no workspace is supplied. A + baseline lookup that fails is reported as an API error instead of resolving to + an empty baseline, and temporary scans are skipped when selecting one. + ## 2.8.2 ### Changed: bump pinned @coana-tech/cli to 15.10.41 diff --git a/README.md b/README.md index 5f978d59..5221daf1 100644 --- a/README.md +++ b/README.md @@ -44,21 +44,22 @@ socketcli --enable-gitlab-security --gitlab-security-file gl-dependency-scanning ### PR scan diffed against the merge base -By default, PR scans are diffed against the repository's latest head scan. To diff against -the exact commit your PR branched from instead, pass the merge base as the baseline: +By default, PR scans are diffed against the repository's latest matching head scan. To +prefer the commit your PR branched from as the baseline, pass the merge base: ```bash BASE_SHA=$(git merge-base origin/main HEAD) socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" ``` -> **Requirement:** `--base-commit-sha` only works if Socket already has a full scan for that -> exact commit. In practice this means your CI must run `socketcli` on **every commit that -> lands on your default branch** โ€” not just some of them. If merges can land without a scan -> (skipped/canceled builds, `[skip ci]`, path-filtered pipelines), the PR scan will fail with -> exit code 3 rather than silently diff against the wrong baseline. See +> The CLI uses the exact commit's newest matching full scan when one exists. Otherwise, it +> searches up to 100 first-parent commits in the local checkout and uses the nearest scanned +> ancestor, with a warning that the diff is wider than the merge base. Run `socketcli` +> regularly on your default branch and ensure PR checkouts contain enough history for that +> walk. The run fails with the configured API-error exit code only when no scanned ancestor +> is reachable (or when the scan lookup itself fails). See > [`docs/cli-reference.md`](https://github.com/SocketDev/socket-python-cli/blob/main/docs/cli-reference.md) -> for the full requirements and a backfill pattern that makes PR jobs self-sufficient. +> for the full behavior and an optional exact-baseline backfill pattern. A specific full scan ID also works: `--base-scan-id `. diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 968799b5..2051a691 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -347,11 +347,14 @@ generator rather than a static YAML file: BASE_SHA=$(git merge-base "origin/${TARGET}" HEAD) ``` -- **Emit the backfill step conditionally from the generator.** The generator is the - natural place for the "does a baseline scan exist?" check +- **If an exact baseline is required, emit a backfill step conditionally from the + generator.** The generator is the natural place for the "does an exact baseline + scan exist?" check (`GET /orgs/{org}/full-scans?repo=&commit_hash=$BASE_SHA&per_page=1`): only emit the baseline-scan step when it returns nothing. The emitted pipeline then shows - in the UI whether a backfill will run. + in the UI whether a backfill will run. Without a backfill, the CLI automatically + uses the nearest scanned first-parent ancestor within 100 commits and warns that + the diff is wider. - **Keep the backfill inside one command step.** The checkout-base โ†’ scan โ†’ checkout-PR sequence must not be split across steps โ€” steps can land on different @@ -360,17 +363,18 @@ generator rather than a static YAML file: checkout: `git worktree add /tmp/socket-base "$BASE_SHA"` then `socketcli --target-path /tmp/socket-base --branch "$TARGET" --disable-blocking`. -- **Soft-fail infra errors, not findings.** A missing baseline (or any API error) - exits with code 3 (`--exit-code-on-api-error` to change it); real findings exit 1. +- **Soft-fail infra errors, not findings.** No reachable scanned ancestor (or any API + error) exits with code 3 (`--exit-code-on-api-error` to change it); real findings exit 1. [`soft_fail: [{exit_status: 3}]`](https://buildkite.com/docs/pipelines/configure/step-types/command-step) on the PR scan step keeps infra errors from blocking merges while security findings still do. - **["Cancel intermediate builds"](https://buildkite.com/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds) - on the default branch is the main source of baseline gaps.** Canceled builds never - scan their commit, so merge-base lookups for PRs based on those commits fail. The - conditional backfill step above is the remedy; there is no per-step exemption from - build cancellation in Buildkite. If you need strict scan-once semantics for + on the default branch is a common source of exact-baseline gaps.** Canceled builds + never scan their commit, so these PRs fall back to an older scanned ancestor. Use + the conditional backfill step above when an exact merge-base comparison is required; + there is no per-step exemption from build cancellation in Buildkite. If you need + strict scan-once semantics for concurrent backfills of the same merge base, serialize the backfill step with a [concurrency group](https://buildkite.com/docs/pipelines/configure/workflows/controlling-concurrency) keyed on the merge-base SHA. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2cc3999e..2ee42450 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -256,24 +256,22 @@ If you don't want to provide the Socket API Token every time then you can use th | `--commit-message` | False | *auto* | Commit message (auto-detected from git) | | `--commit-sha` | False | *auto* | Commit SHA (auto-detected from git) | | `--base-scan-id` | False | | Full scan ID to diff against, overriding the repository's head scan as the baseline. Mutually exclusive with `--base-commit-sha` | -| `--base-commit-sha`| False | | Commit SHA to diff against, overriding the repository's head scan as the baseline. The most recent full scan for that commit is used; the CLI errors (exit code 3, or `--exit-code-on-api-error`) if no scan exists for it. Mutually exclusive with `--base-scan-id` | +| `--base-commit-sha`| False | | Commit SHA to prefer as the diff baseline, overriding the repository's head scan. The CLI uses its most recent matching full scan or the nearest scanned first-parent ancestor within 100 local commits. It errors (exit code 3, or `--exit-code-on-api-error`) if no scanned ancestor is reachable. Mutually exclusive with `--base-scan-id` | -> **Diffing against the merge base** โ€” by default, PR scans are diffed against the repository's *latest* head scan, which may include newer default-branch commits than your PR branched from. To diff against the exact commit your PR is based on, compute the merge base and pass it as the baseline: +> **Diffing against the merge base** โ€” by default, PR scans are diffed against the repository's latest matching head scan, which may include newer default-branch commits than your PR branched from. To prefer the commit your PR is based on, compute the merge base and pass it as the baseline: > > ```shell > BASE_SHA=$(git merge-base origin/main HEAD) > socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" > ``` > -> **Requirement: a full scan must already exist for the merge-base commit.** `--base-commit-sha` does not create a scan of that commit; it looks up an existing one. That lookup only succeeds if your CI runs `socketcli` on **every commit that lands on your default branch** โ€” every merge and direct push, not just periodic or latest-only scans. Common ways commits slip through without a scan: +> `--base-commit-sha` does not create a scan of that commit. The CLI first looks for the newest non-temporary scan matching the repository, workspace, scan type, and exact commit. If the exact commit was not scanned, it walks up to 100 first-parent commits from that SHA in the local checkout and uses the nearest matching scanned ancestor. It logs a warning with the selected commit and distance because this produces a wider diff than the merge base. > -> - CI settings that cancel or skip intermediate builds when newer commits land (e.g. Buildkite's ["cancel intermediate builds"](https://buildkite.com/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds)) -> - `[skip ci]` commits, path-filtered pipelines, or failed/canceled scan steps -> - merge-base commits that predate your Socket rollout +> Run `socketcli` regularly on the default branch so recent ancestors have scans. PR checkouts must also retain the merge base and enough first-parent history; shallow clones can shorten the search. Gaps are expected when CI cancels intermediate builds, commits use `[skip ci]`, pipelines are path-filtered, or the merge base predates your Socket rollout. > -> If no scan exists for the commit, the CLI **fails** (exit code 3, or your `--exit-code-on-api-error` value; exit 0 with `--disable-blocking`) instead of silently falling back to the head scan โ€” a wrong baseline would misreport which alerts the PR introduces. Don't adopt this flag without default-branch scan coverage in place; you'll fail PR builds on lookup misses. +> If no scanned ancestor is reachable within the local 100-commit walk, the CLI **fails** (exit code 3, or your `--exit-code-on-api-error` value; exit 0 with `--disable-blocking`) instead of silently falling back to the repository head. API or permission failures also fail rather than being treated as a missing exact scan. > -> **Backfill pattern** โ€” if your default-branch coverage has gaps, the PR job can create the missing baseline itself before scanning: +> **Optional exact-baseline backfill** โ€” if the wider ancestor fallback is not acceptable, the PR job can create the missing exact baseline before scanning: > > ```shell > BASE_SHA=$(git merge-base origin/main HEAD) @@ -285,7 +283,7 @@ If you don't want to provide the Socket API Token every time then you can use th > socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" > ``` > -> Run the baseline step with `--disable-blocking` (findings on the default branch must not fail the PR job) and an explicit `--branch`, since branch auto-detection is unreliable at a detached HEAD. +> Run the baseline step with `--disable-blocking` (findings on the default branch must not fail the PR job) and an explicit `--branch`, since branch auto-detection is unreliable at a detached HEAD. Without this step, the CLI automatically uses the nearest scanned ancestor. > > Buildkite users with dynamically generated pipelines: see [Merge-base baselines in Buildkite](ci-cd.md#merge-base-baselines-in-buildkite-dynamic-pipelines) for generation-time vs. step-time guidance. diff --git a/pyproject.toml b/pyproject.toml index 5f812f5f..1c30bc61 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.2" +version = "2.8.3" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 29fd46e0..c5cb4808 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.2' +__version__ = '2.8.3' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 35904976..ecd2f287 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -587,9 +587,9 @@ def create_argument_parser() -> argparse.ArgumentParser: metavar="", default=None, help="Commit SHA to diff the new scan against, overriding the repository's head " - "scan as the baseline. The most recent full scan matching this commit (e.g. " - "the merge base from 'git merge-base origin/main HEAD') is used; the CLI " - "errors if no scan exists for it. Mutually exclusive with --base-scan-id." + "scan as the baseline. The CLI uses the most recent matching full scan, or " + "the nearest scanned first-parent ancestor within 100 local commits when " + "the commit itself was not scanned. Mutually exclusive with --base-scan-id." ) # Path and File options diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index b1b1d65b..c5f3f52c 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -14,10 +14,11 @@ import time from dataclasses import asdict from pathlib import PurePath -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional, Set, Tuple +from typing import TYPE_CHECKING, Dict, Iterator, List, NamedTuple, Optional, Set, Tuple if TYPE_CHECKING: from socketsecurity.config import CliConfig +from git import Repo from socketdev import socketdev from socketdev.exceptions import APIFailure from socketdev.fullscans import DiffArtifacts, FullScanParams, SocketArtifact @@ -51,6 +52,17 @@ _HUMANIZE_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])") +# How many full scans to request when resolving a diff baseline. The newest scan is +# usually the one we want, but temporary scans have to be skipped (see +# Core.newest_persisted_scan_id), so a single result is not enough. +SCAN_LOOKUP_PAGE_SIZE = 10 + +# Bounds on the search for a scanned ancestor when the requested baseline commit has +# no full scan of its own. Full-scan listing pages are matched against bounded local +# history, so the local walk cannot grow without limit. +ANCESTOR_SCAN_LOOKUP_LIMIT = 100 +ANCESTOR_WALK_MAX_DEPTH = 100 + # Reachability facts-file upload compression. # # The Socket full-scan endpoint transparently brotli-decompresses any multipart part @@ -1463,19 +1475,229 @@ def get_repo_info(self, repo_slug: str, default_branch: str = "socket-default-br return response.data - def get_head_scan_for_repo(self, repo_slug: str) -> str: + def get_head_scan_for_repo( + self, + repo_slug: str, + workspace: Optional[str] = None, + scan_type: Optional[str] = None, + ) -> Optional[str]: """ Gets the head scan ID for a repository. + Without a workspace or scan type this is the repository's head scan pointer. + That pointer tracks a single scan for the whole repository rather than one per + workspace or scan type, so scoped runs instead take the newest matching scan + on the default branch. + Args: repo_slug: Repository slug to get head scan for + workspace: Socket workspace the scan belongs to, if any + scan_type: Socket scan type to match, if any Returns: Head scan ID if it exists, None otherwise + + Raises: + APIFailure: If the scoped scan lookup fails. A failed lookup must not + be reported as "no baseline": the caller answers that by creating an + empty baseline scan, which reports every dependency in the repository + as newly added. """ repo_info = self.get_repo_info(repo_slug) + if workspace or scan_type: + query_params = { + "repo": repo_slug, + "branch": repo_info.default_branch, + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + } + if workspace: + query_params["workspace"] = workspace + if scan_type: + query_params["scan_type"] = Core.query_param_value(scan_type) + for results in self._full_scan_result_pages( + query_params, + f"Failed to list matching full scans for repo {repo_slug}", + ): + scan_id = Core.newest_persisted_scan_id(results) + if scan_id: + return scan_id + return None return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None + @staticmethod + def query_param_value(value): + """ + Unwraps an enum member so it survives URL encoding. + + The SDK types several query params as str-backed enums (ScanType, + IntegrationType). urlencode calls str(), which renders a (str, Enum) member + as "ScanType.SOCKET_TIER1" -- a filter value the API does not recognize. + """ + return getattr(value, "value", value) + + @staticmethod + def newest_persisted_scan_id(results: List[dict]) -> Optional[str]: + """ + Returns the newest scan ID from a full scan listing, skipping temporary scans. + + create_new_diff creates an empty ``tmp`` scan when a repository has no baseline + yet, and that scan inherits the branch and commit of the run that created it. + If the real scan then fails, the empty scan is left behind as the newest scan + for that branch/commit; selecting it as a baseline would report every + dependency as newly added. + + Args: + results: Full scan listing results, newest first + + Returns: + Newest non-temporary scan ID, or None if the listing has none + """ + for result in results or []: + if not isinstance(result, dict) or result.get("tmp"): + continue + scan_id = result.get("id") + if scan_id: + return scan_id + return None + + def _full_scan_result_pages( + self, + query_params: dict, + failure_message: str, + ) -> Iterator[List[dict]]: + """Yields successful full-scan listing pages and rejects failed lookups.""" + request_params = dict(query_params) + seen_pages = {str(request_params.get("page", 1))} + per_page = int(request_params.get("per_page", 30)) + + while True: + response = self.sdk.fullscans.get(self.config.org_slug, request_params) + results = response.get("results") if isinstance(response, dict) else None + if results is None: + # The SDK logs and returns {} for any non-200, so a present results + # key is the only signal that the request itself succeeded. + raise APIFailure(failure_message) + yield results + + next_page = response.get("nextPage") + # The API has historically returned nextPage=1 for a short final page. + if len(results) < per_page or next_page in (None, 0, "0", False, ""): + return + + page_key = str(next_page) + if page_key in seen_pages: + raise APIFailure( + f"{failure_message}: full-scan pagination repeated page {next_page}" + ) + seen_pages.add(page_key) + request_params = {**query_params, "page": next_page} + + def first_parent_commits(self, start_commit_sha: str, max_count: int) -> List[str]: + """ + Lists a commit and its first-parent ancestors, newest first. + + Follows only first parents so a merge commit contributes the branch's own + history rather than everything merged into it. A shallow checkout simply + yields fewer commits, which narrows the search rather than failing it. + + Args: + start_commit_sha: Commit to walk back from, included in the result + max_count: Maximum number of commits to return + + Returns: + Commit SHAs, newest first. Empty when the repository or commit is + unavailable locally. + """ + target_path = self.cli_config.target_path if self.cli_config else None + if not target_path: + return [] + try: + repo = Repo(target_path) + output = repo.git.rev_list( + "--first-parent", + f"--max-count={max_count}", + start_commit_sha, + ) + except Exception as error: + log.debug(f"Unable to walk history back from {start_commit_sha}: {error}") + return [] + return [line.strip() for line in output.splitlines() if line.strip()] + + def find_baseline_scan_for_ancestor( + self, + repo_slug: str, + commit_sha: str, + workspace: Optional[str] = None, + scan_type: Optional[str] = None, + ) -> Tuple[Optional[str], Optional[str], int]: + """ + Finds the nearest ancestor of a commit that does have a full scan. + + Used when --base-commit-sha names a commit that was never scanned. Squash + merges and rebases rewrite commits, and a multi-commit push produces one scan + for the tip, so a merge base can be unscanned even when default-branch + scanning is configured correctly. Diffing against a slightly older ancestor + is a wider diff; failing outright is no diff at all. + + Scan listing pages are matched against local first-parent history. All pages + are considered because scans from other branches and reruns can fill newer + pages without covering the nearest candidate ancestors. + + Args: + repo_slug: Repository slug the scan belongs to + commit_sha: Commit that has no full scan of its own + workspace: Socket workspace the scan belongs to, if any + scan_type: Socket scan type to match, if any + + Returns: + (scan_id, ancestor_commit_sha, commits_back), or (None, None, 0) when no + scanned ancestor is reachable. + """ + ancestors = self.first_parent_commits(commit_sha, ANCESTOR_WALK_MAX_DEPTH) + if not ancestors: + return None, None, 0 + + query_params = { + "repo": repo_slug, + "sort": "created_at", + "direction": "desc", + "per_page": ANCESTOR_SCAN_LOOKUP_LIMIT, + } + if workspace: + query_params["workspace"] = workspace + if scan_type: + query_params["scan_type"] = Core.query_param_value(scan_type) + + scans_by_commit = {} + ancestor_set = set(ancestors) + for results in self._full_scan_result_pages( + query_params, + f"Failed to list ancestor full scans for repo {repo_slug}", + ): + for result in results: + if not isinstance(result, dict) or result.get("tmp"): + continue + result_commit = result.get("commit_hash") + scan_id = result.get("id") + # Newest first, so the first scan seen for a commit is the one to keep. + if ( + result_commit in ancestor_set + and scan_id + and result_commit not in scans_by_commit + ): + scans_by_commit[result_commit] = scan_id + + if not scans_by_commit: + return None, None, 0 + + for distance, ancestor in enumerate(ancestors): + scan_id = scans_by_commit.get(ancestor) + if scan_id: + return scan_id, ancestor, distance + return None, None, 0 + def get_full_scan_id_by_commit( self, repo_slug: str, @@ -1504,31 +1726,32 @@ def get_full_scan_id_by_commit( "commit_hash": commit_sha, "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, } if workspace: query_params["workspace"] = workspace if scan_type: - query_params["scan_type"] = scan_type + query_params["scan_type"] = Core.query_param_value(scan_type) - response = self.sdk.fullscans.get( - self.config.org_slug, - query_params, - ) - results = response.get("results") if isinstance(response, dict) else None - if not results: - return None - return results[0].get("id") + for results in self._full_scan_result_pages( + query_params, + f"Failed to list full scans for commit {commit_sha} in repo {repo_slug}", + ): + scan_id = Core.newest_persisted_scan_id(results) + if scan_id: + return scan_id + return None def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: """ Resolves the baseline full scan ID to diff a new scan against. Priority: --base-scan-id (used verbatim), then --base-commit-sha (newest - full scan for that commit), then the repository's current head scan. A - --base-commit-sha with no matching full scan is a hard error rather than a - silent fallback to the head scan, because diffing against the wrong - baseline silently misreports which alerts a PR introduces. + full scan for that commit, or its nearest scanned first-parent ancestor), + then the repository's current matching head scan. A --base-commit-sha with + no reachable scanned ancestor is a hard error rather than a silent fallback + to the head scan, because diffing against the wrong baseline silently + misreports which alerts a PR introduces. Returns: Full scan ID to use as the diff baseline, or None when the repository @@ -1549,26 +1772,61 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: workspace=params.workspace, scan_type=params.scan_type, ) + baseline_source = "explicit-commit" + baseline_commit = commit_sha if scan_id is None: - log.error( - f"No full scan found for commit {commit_sha} in repo {params.repo} " - "(--base-commit-sha). Ensure a scan was created for that commit " - "(e.g. the CLI runs on default-branch pushes), or pass " - "--base-scan-id instead." + scan_id, ancestor_sha, commits_back = self.find_baseline_scan_for_ancestor( + params.repo, + commit_sha, + workspace=params.workspace, + scan_type=params.scan_type, ) - if self.cli_config.disable_blocking: - sys.exit(0) - sys.exit(self.cli_config.exit_code_on_api_error) + if scan_id: + baseline_source = "explicit-commit-ancestor" + baseline_commit = ancestor_sha + log.warning( + f"No full scan for commit {commit_sha} (--base-commit-sha). " + f"Diffing against its nearest scanned ancestor {ancestor_sha}, " + f"{commits_back} commit(s) earlier, so the diff is wider than " + "the merge base." + ) + else: + log.error( + f"No full scan found for commit {commit_sha} in repo {params.repo} " + "(--base-commit-sha), and no scanned ancestor within " + f"{ANCESTOR_WALK_MAX_DEPTH} commits of it. Ensure a scan was " + "created for that commit (e.g. the CLI runs on default-branch " + "pushes), or pass --base-scan-id instead." + ) + if self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(self.cli_config.exit_code_on_api_error) log.info( - "Baseline selected: source=explicit-commit " - f"scan_id={json.dumps(scan_id)} commit={json.dumps(commit_sha)}" + f"Baseline selected: source={baseline_source} " + f"scan_id={json.dumps(scan_id)} commit={json.dumps(baseline_commit)}" ) return scan_id try: - scan_id = self.get_head_scan_for_repo(params.repo) + scan_id = self.get_head_scan_for_repo( + params.repo, + workspace=params.workspace, + scan_type=params.scan_type, + ) except APIResourceNotFound: return None + except APIFailure as error: + # Returning None would make the caller create an empty baseline scan, + # reporting every dependency as newly added, so fail loudly like the + # --base-commit-sha path above. + log.error( + f"Failed to resolve the matching head scan for repo {params.repo}: {error}" + ) + if self.cli_config is None: + raise + if self.cli_config.disable_blocking: + sys.exit(0) + sys.exit(self.cli_config.exit_code_on_api_error) if scan_id: log.info( "Baseline selected: source=repository-head " @@ -1578,12 +1836,11 @@ def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: @staticmethod def update_package_values(pkg: Package) -> Package: + pkg.type = Package.normalize_type(pkg.type) pkg.purl = f"{pkg.name}@{pkg.version}" - pkg.url = f"https://socket.dev/{pkg.type}/package" if pkg.namespace: pkg.purl = f"{pkg.namespace}/{pkg.purl}" - pkg.url += f"/{pkg.namespace}" - pkg.url += f"/{pkg.name}/overview/{pkg.version}" + pkg.url = Package.socket_url(pkg.type, pkg.namespace, pkg.name, pkg.version) return pkg def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: int = 5000) -> dict: @@ -2407,6 +2664,8 @@ def add_package_alerts_to_collection(self, package: Package, alerts_collection: suggestion=props.suggestion, next_step_title=props.nextStepTitle, introduced_by=introduced_by, + manifest_files=package.manifestFiles or [], + direct=bool(package.direct), purl=package.purl, url=package.url ) diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index db145221..2a19c2bb 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -1,4 +1,5 @@ import json +import logging from dataclasses import dataclass, field from typing import Dict, List, Optional, TypedDict @@ -11,6 +12,13 @@ SocketScore, ) +log = logging.getLogger("socketdev") + +# Ecosystems whose package pages cannot be addressed by name alone. A Maven +# coordinate is a groupId plus an artifactId; with no namespace the URL collapses to +# one path segment that cannot be split back into two, and the page does not resolve. +NAMESPACE_REQUIRED_TYPES = frozenset({"maven"}) + __all__ = [ "Report", "Score", @@ -142,6 +150,43 @@ class Package(): licenseAttrib: Optional[List] = None + @staticmethod + def normalize_type(package_type) -> str: + """ + Unwraps the SDK's str-backed SocketPURL_Type enum to its value. + + str(SocketPURL_Type.MAVEN) is "SocketPURL_Type.MAVEN", not "maven", so any + enum member reaching an f-string leaks the class name into user-facing output. + """ + return getattr(package_type, "value", package_type) + + @staticmethod + def socket_url(package_type, namespace: Optional[str], name: str, version: str) -> str: + """ + Builds the socket.dev package overview URL for a package. + + Namespace and name are separate path segments, the same form purl strings use. + + Args: + package_type: Ecosystem, as a string or SocketPURL_Type member + namespace: Package namespace (Maven groupId, npm scope), if any + name: Package name + version: Package version + + Returns: + Package overview URL on socket.dev + """ + package_type = Package.normalize_type(package_type) + namespace = (namespace or "").strip("/") + if not namespace and package_type in NAMESPACE_REQUIRED_TYPES: + # The link is still emitted so the finding reports, but it cannot resolve. + log.warning( + f"{package_type} package {name}@{version} has no namespace, so its " + "Socket link collapses to a single path segment and will not resolve" + ) + package_path = "/".join(part for part in (namespace, name) if part) + return f"https://socket.dev/{package_type}/package/{package_path}/overview/{version}" + @classmethod def from_socket_artifact(cls, data: dict) -> "Package": """ @@ -153,18 +198,16 @@ def from_socket_artifact(cls, data: dict) -> "Package": Returns: New Package instance """ - purl = f"{data['type']}/" - namespace = data.get("namespace") - if namespace: - purl += f"{namespace}@" - purl += f"{data['name']}@{data['version']}" - base_url = "https://socket.dev" - url = f"{base_url}/{data['type']}/package/{namespace or ''}{data['name']}/overview/{data['version']}" + package_type = Package.normalize_type(data["type"]) + namespace = (data.get("namespace") or "").strip("/") + package_path = "/".join(part for part in (namespace, data["name"]) if part) + purl = f"{package_type}/{package_path}@{data['version']}" + url = Package.socket_url(package_type, namespace, data["name"], data["version"]) return cls( id=data["id"], name=data["name"], version=data["version"], - type=data["type"], + type=package_type, release=data.get("release"), diffType=data.get("diffType"), score=data["score"], @@ -179,7 +222,7 @@ def from_socket_artifact(cls, data: dict) -> "Package": artifact=data.get("artifact"), purl=purl, url=url, - namespace=namespace + namespace=namespace or None ) @classmethod @@ -274,6 +317,11 @@ class Issue: manifests: str url: str purl: str + # The package's own manifest files, independent of how it was introduced. A + # transitive package whose ancestors are absent from the scan has no + # introduced_by chain, but its manifest is still known. + manifest_files: list + direct: bool def __init__(self, **kwargs): if kwargs: @@ -282,6 +330,10 @@ def __init__(self, **kwargs): if hasattr(self, "created_at"): self.created_at = self.created_at.strip(" (Coordinated Universal Time)") + if not hasattr(self, "manifest_files"): + self.manifest_files = [] + if not hasattr(self, "direct"): + self.direct = False if not hasattr(self, "manifests"): self.manifests = "" if not hasattr(self, "suggestion"): diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index d968c14b..14a6f829 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -648,32 +648,48 @@ def extract_identifiers_gitlab(alert: Issue) -> list: """ identifiers = [] - # Primary identifier: Socket alert type - identifiers.append({ + # Primary identifier: Socket alert type. The GitLab schema types identifier + # url as a string matching ^(https?|ftp)://, so an absent url is omitted + # rather than sent as null, which fails validation for the whole finding. + socket_identifier = { "type": "socket_alert", "name": f"Socket {alert.type}", "value": alert.type, - "url": alert.url if hasattr(alert, 'url') and alert.url else None - }) - - # Extract CVE identifiers from props - if hasattr(alert, 'props') and alert.props: - if 'cve' in alert.props: - cves = alert.props['cve'] - if isinstance(cves, list): - for cve in cves: - identifiers.append({ - "type": "cve", - "name": cve, - "value": cve, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cve}" - }) - elif isinstance(cves, str): + } + alert_url = getattr(alert, "url", None) + if alert_url: + socket_identifier["url"] = alert_url + identifiers.append(socket_identifier) + + props = getattr(alert, "props", None) or {} + # Alerts reach Issue.props from several sources, so both spellings of each + # field are in play; core.alert_selection matches on the same pair. "cve" is + # the older spelling and still appears in some payloads. + identifier_fields = ( + (("cveId", "cve_id", "cve"), "cve", "https://nvd.nist.gov/vuln/detail/"), + (("ghsaId", "ghsa_id"), "ghsa", "https://github.com/advisories/"), + ) + seen = set() + for fields, identifier_type, url_prefix in identifier_fields: + for field in fields: + values = props.get(field) + if isinstance(values, str): + values = [values] + elif not isinstance(values, (list, tuple)): + continue + for value in values: + if not isinstance(value, str) or not value.strip(): + continue + value = value.strip() + identifier_key = (identifier_type, value.upper()) + if identifier_key in seen: + continue + seen.add(identifier_key) identifiers.append({ - "type": "cve", - "name": cves, - "value": cves, - "url": f"https://cve.mitre.org/cgi-bin/cvename.cgi?name={cves}" + "type": identifier_type, + "name": value, + "value": value, + "url": f"{url_prefix}{value}" }) return identifiers @@ -686,37 +702,35 @@ def extract_location_gitlab(alert: Issue) -> dict: GitLab location requires: - file: path to manifest file - dependency: package name and version - - dependency_path (optional): dependency chain """ - # Get manifest file from introduced_by or manifests attribute - manifest_file = "unknown" - dependency_path = [] - is_direct = True - - if hasattr(alert, 'introduced_by') and alert.introduced_by: - if isinstance(alert.introduced_by, list) and len(alert.introduced_by) > 0: - first_entry = alert.introduced_by[0] - if isinstance(first_entry, (list, tuple)) and len(first_entry) >= 2: - dependency_path_str = first_entry[0] - manifest_file = first_entry[1].split(';')[0] if ';' in first_entry[1] else first_entry[1] - - # Parse dependency path - if ' > ' in dependency_path_str: - dependency_path = dependency_path_str.split(' > ') - # If there's a chain, it's transitive (not direct) - is_direct = len(dependency_path) <= 1 - - elif hasattr(alert, 'manifests') and alert.manifests: - manifest_file = alert.manifests.split(';')[0] + manifest_file = "" + + introduced_by = getattr(alert, "introduced_by", None) + if isinstance(introduced_by, list) and introduced_by: + first_entry = introduced_by[0] + if isinstance(first_entry, (list, tuple)) and len(first_entry) >= 2: + manifest_file = (first_entry[1] or "").split(";")[0] + + if not manifest_file: + manifest_file = (getattr(alert, "manifests", "") or "").split(";")[0] + + if not manifest_file: + # A transitive package whose ancestors are not in this scan has no + # introduced_by chain, but the package still records its own manifest. + for entry in getattr(alert, "manifest_files", None) or []: + candidate = entry.get("file") if isinstance(entry, dict) else None + if candidate: + manifest_file = candidate + break location = { - "file": manifest_file, + "file": manifest_file or "unknown", "dependency": { "package": { "name": alert.pkg_name }, "version": alert.pkg_version, - "direct": is_direct + "direct": bool(getattr(alert, "direct", False)) } } diff --git a/tests/core/test_package_and_alerts.py b/tests/core/test_package_and_alerts.py index 171eae77..0e096621 100644 --- a/tests/core/test_package_and_alerts.py +++ b/tests/core/test_package_and_alerts.py @@ -1,8 +1,9 @@ -from dataclasses import dataclass +from dataclasses import asdict, dataclass from unittest.mock import Mock import pytest from socketdev import socketdev +from socketdev.fullscans import SocketArtifact from socketsecurity.core import Core, _humanize_alert_type from socketsecurity.core.classes import Issue, Package @@ -104,6 +105,93 @@ def test_create_packages_dict_basic(self, core): assert pkg.version == "1.0.0" assert pkg.transitives == 0 + def test_full_scan_package_normalizes_enum_type_and_namespace_url(self): + artifact = SocketArtifact.from_dict({ + "id": "pkg:maven/com.example/example-core@1.2.3", + "type": "maven", + "namespace": "com.example", + "name": "example-core", + "version": "1.2.3", + "direct": True, + "topLevelAncestors": [], + "manifestFiles": [{"file": "pom.xml"}], + "alerts": [], + }) + + package = Package.from_socket_artifact(asdict(artifact)) + + assert package.type == "maven" + assert package.purl == "maven/com.example/example-core@1.2.3" + assert package.url == ( + "https://socket.dev/maven/package/com.example/example-core/overview/1.2.3" + ) + + def test_maven_package_url_separates_group_and_artifact(self): + """groupId and artifactId are distinct path segments, not one fused string""" + artifact = SocketArtifact.from_dict({ + "id": "pkg:maven/org.apache.logging.log4j/log4j-api@2.17.2", + "type": "maven", + "namespace": "org.apache.logging.log4j", + "name": "log4j-api", + "version": "2.17.2", + "direct": True, + "topLevelAncestors": [], + "manifestFiles": [{"file": "pom.xml"}], + "alerts": [], + }) + + package = Package.from_socket_artifact(asdict(artifact)) + + assert package.url == ( + "https://socket.dev/maven/package/org.apache.logging.log4j/log4j-api" + "/overview/2.17.2" + ) + # The purl keeps the "/" form, which is what the purl spec and the purl API want. + assert package.purl == "maven/org.apache.logging.log4j/log4j-api@2.17.2" + + def test_non_maven_package_url_keeps_slash_separator(self): + """npm scopes and Go module paths stay slash-delimited""" + scoped_npm = Package.socket_url("npm", "@babel", "core", "7.0.0") + assert scoped_npm == "https://socket.dev/npm/package/@babel/core/overview/7.0.0" + + unscoped = Package.socket_url("nuget", None, "newtonsoft.json", "6.0.8") + assert unscoped == "https://socket.dev/nuget/package/newtonsoft.json/overview/6.0.8" + + def test_maven_package_without_namespace_warns(self, caplog): + """A Maven coordinate missing its groupId cannot produce a resolvable link""" + with caplog.at_level("WARNING", logger="socketdev"): + url = Package.socket_url("maven", None, "orphan-artifact", "1.0.0") + + assert url == "https://socket.dev/maven/package/orphan-artifact/overview/1.0.0" + assert "orphan-artifact@1.0.0" in caplog.text + assert "no namespace" in caplog.text + + def test_namespaced_maven_package_does_not_warn(self, caplog): + """The warning is for missing data, not for every Maven package""" + with caplog.at_level("WARNING", logger="socketdev"): + Package.socket_url("maven", "com.example", "artifact", "1.0.0") + + assert caplog.text == "" + + def test_diff_path_builds_the_same_maven_url_as_the_full_scan_path(self): + """Both package construction paths must agree, or links break on only some runs""" + package = Package( + id="pkg:maven/com.google.code.gson/gson@2.8.6", + type="maven", + name="gson", + version="2.8.6", + namespace="com.google.code.gson", + score={}, + alerts=[], + topLevelAncestors=[], + ) + + package = Core.update_package_values(package) + + assert package.url == ( + "https://socket.dev/maven/package/com.google.code.gson/gson/overview/2.8.6" + ) + def test_create_packages_dict_with_transitives(self, core): """Test package dictionary creation with transitive dependencies""" mock_artifacts = [ @@ -340,4 +428,3 @@ def test_empty_input_returns_empty_string(self): def test_handles_acronyms_conservatively(self): """Adjacent capitals are kept together: SQLInjection -> 'SQL Injection'.""" assert _humanize_alert_type("SQLInjection") == "SQL Injection" - diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index d79f62f3..bf439a3b 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,9 +1,9 @@ import pytest from socketdev.exceptions import APIFailure -from socketdev.fullscans import FullScanParams, FullScanStreamResponse +from socketdev.fullscans import FullScanParams, FullScanStreamResponse, ScanType from socketsecurity.config import CliConfig -from socketsecurity.core import Core +from socketsecurity.core import ANCESTOR_SCAN_LOOKUP_LIMIT, SCAN_LOOKUP_PAGE_SIZE, Core from socketsecurity.core.socket_config import SocketConfig @@ -63,6 +63,106 @@ def test_get_head_scan_for_repo_no_head(core, mock_sdk_with_responses): head_scan_id = core.get_head_scan_for_repo("no-head") assert head_scan_id is None + +def test_get_head_scan_for_repo_scopes_workspace_to_default_branch( + core, mock_sdk_with_responses +): + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + head_scan_id = core.get_head_scan_for_repo( + "test", + workspace="customer-a", + scan_type="socket_tier1", + ) + + assert head_scan_id == "workspace-head" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "workspace": "customer-a", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + "scan_type": "socket_tier1", + }, + ) + + +def test_get_head_scan_for_repo_scopes_scan_type_without_workspace( + core, mock_sdk_with_responses +): + """Reachability and standard scans must not share one unscoped head pointer""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "standard-head"}], + "nextPage": None, + } + + head_scan_id = core.get_head_scan_for_repo("test", scan_type="socket") + + assert head_scan_id == "standard-head" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + "scan_type": "socket", + }, + ) + + +def test_get_head_scan_for_repo_workspace_lookup_failure_raises(core, mock_sdk_with_responses): + """A failed listing is not the same as an empty one and must not resolve to None""" + mock_sdk_with_responses.fullscans.get.return_value = {} + + with pytest.raises(APIFailure): + core.get_head_scan_for_repo("test", workspace="customer-a") + + +def test_get_head_scan_for_repo_workspace_no_scans_yet(core, mock_sdk_with_responses): + """An empty listing is a real answer: the workspace has no baseline yet""" + mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} + + assert core.get_head_scan_for_repo("test", workspace="customer-a") is None + + +def test_get_head_scan_for_repo_skips_temporary_scans(core, mock_sdk_with_responses): + """A leftover empty tmp scan must not be picked up as the baseline""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [ + {"id": "leftover-tmp-scan", "tmp": True}, + {"id": "workspace-head", "tmp": False}, + ], + "nextPage": None, + } + + assert core.get_head_scan_for_repo("test", workspace="customer-a") == "workspace-head" + + +def test_get_head_scan_for_repo_normalizes_enum_scan_type(core, mock_sdk_with_responses): + """ScanType members must be sent as their value, not their repr-style name""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + core.get_head_scan_for_repo( + "test", + workspace="customer-a", + scan_type=ScanType.SOCKET_TIER1, + ) + + query_params = mock_sdk_with_responses.fullscans.get.call_args.args[1] + assert query_params["scan_type"] == "socket_tier1" + + def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): """Looks up the newest full scan for a repo + commit via the list endpoint""" mock_sdk_with_responses.fullscans.get.return_value = { @@ -80,7 +180,7 @@ def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, }, ) @@ -107,24 +207,105 @@ def test_get_full_scan_id_by_commit_scopes_to_workspace_and_scan_type(core, mock "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "workspace": "customer-a", "scan_type": "socket_tier1", }, ) +def test_get_full_scan_id_by_commit_skips_temporary_scans(core, mock_sdk_with_responses): + """A tmp scan carries the commit hash of the run that created it, so skip it too""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [ + {"id": "leftover-tmp-scan", "commit_hash": "abc123", "tmp": True}, + {"id": "base-scan-id", "commit_hash": "abc123"}, + ], + "nextPage": None, + } + + assert core.get_full_scan_id_by_commit("test", "abc123") == "base-scan-id" + + def test_get_full_scan_id_by_commit_not_found(core, mock_sdk_with_responses): - """No scan for the commit returns None (empty results and SDK error dict)""" + """A successful empty listing means the commit has no scan""" mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} assert core.get_full_scan_id_by_commit("test", "abc123") is None + +def test_get_full_scan_id_by_commit_lookup_failure_raises(core, mock_sdk_with_responses): + """An SDK error dict must not trigger fallback to an older ancestor""" mock_sdk_with_responses.fullscans.get.return_value = {} - assert core.get_full_scan_id_by_commit("test", "abc123") is None + with pytest.raises(APIFailure): + core.get_full_scan_id_by_commit("test", "abc123") + def test_resolve_base_full_scan_id_defaults_to_head_scan(core): - """Without base overrides the repository head scan is the baseline""" - assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + """Without base overrides the matching scan type's head scan is the baseline""" + core.sdk.fullscans.get.return_value = { + "results": [{"id": "standard-head"}], + "nextPage": None, + } + + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "standard-head" + + +def test_resolve_base_full_scan_id_scopes_head_to_workspace(core): + core.sdk.fullscans.get.return_value = { + "results": [{"id": "workspace-head"}], + "nextPage": None, + } + + params = make_full_scan_params(workspace="customer-a", scan_type="socket_tier1") + + assert core.resolve_base_full_scan_id(params) == "workspace-head" + core.sdk.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "workspace": "customer-a", + "branch": "main", + "sort": "created_at", + "direction": "desc", + "per_page": SCAN_LOOKUP_PAGE_SIZE, + "scan_type": "socket_tier1", + }, + ) + +def test_resolve_base_full_scan_id_workspace_lookup_failure_exits(core): + """A failed workspace lookup fails the run instead of diffing against an empty scan""" + core.cli_config = make_cli_config() + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(params) + assert exc_info.value.code == core.cli_config.exit_code_on_api_error + + +def test_resolve_base_full_scan_id_workspace_lookup_failure_disable_blocking(core): + """--disable-blocking keeps the failed lookup from failing the build""" + core.cli_config = make_cli_config("--disable-blocking") + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(params) + assert exc_info.value.code == 0 + + +def test_resolve_base_full_scan_id_workspace_lookup_failure_without_cli_config(core): + """Library callers with no CliConfig see the APIFailure rather than a process exit""" + core.cli_config = None + core.sdk.fullscans.get.return_value = {} + + params = make_full_scan_params(workspace="customer-a") + + with pytest.raises(APIFailure): + core.resolve_base_full_scan_id(params) + def test_resolve_base_full_scan_id_uses_base_scan_id(core): """--base-scan-id is used verbatim, without touching the repo endpoint""" @@ -152,12 +333,109 @@ def test_resolve_base_full_scan_id_uses_base_commit_sha(core): "commit_hash": "abc123", "sort": "created_at", "direction": "desc", - "per_page": 1, + "per_page": SCAN_LOOKUP_PAGE_SIZE, "workspace": "customer-a", "scan_type": "socket_tier1", }, ) +def test_resolve_base_full_scan_id_falls_back_to_scanned_ancestor(core, monkeypatch): + """An unscanned merge base degrades to the nearest scanned ancestor""" + core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha") + core.sdk.fullscans.get.side_effect = [ + {"results": [], "nextPage": None}, # exact commit + {"results": [ # recent scans + {"id": "tmp-scan", "commit_hash": "ancestor-1", "tmp": True}, + {"id": "ancestor-scan", "commit_hash": "ancestor-2"}, + ], "nextPage": None}, + ] + monkeypatch.setattr( + Core, "first_parent_commits", + lambda self, sha, depth: ["unscanned-sha", "ancestor-1", "ancestor-2"], + ) + + params = make_full_scan_params() + assert core.resolve_base_full_scan_id(params) == "ancestor-scan" + + +def test_find_baseline_scan_for_ancestor_paginates_and_selects_nearest( + core, monkeypatch +): + """Reruns can fill page one while a closer scanned ancestor is on page two""" + first_page = [ + {"id": "farther-scan", "commit_hash": "ancestor-2"}, + *[ + {"id": f"unrelated-{index}", "commit_hash": f"other-{index}"} + for index in range(ANCESTOR_SCAN_LOOKUP_LIMIT - 1) + ], + ] + core.sdk.fullscans.get.side_effect = [ + {"results": first_page, "nextPage": 2}, + { + "results": [{"id": "nearest-scan", "commit_hash": "ancestor-1"}], + "nextPage": 0, + }, + ] + monkeypatch.setattr( + Core, + "first_parent_commits", + lambda self, sha, depth: ["unscanned-sha", "ancestor-1", "ancestor-2"], + ) + + assert core.find_baseline_scan_for_ancestor( + "test", + "unscanned-sha", + scan_type="socket", + ) == ("nearest-scan", "ancestor-1", 1) + assert core.sdk.fullscans.get.call_args_list[1].args[1]["page"] == 2 + + +def test_resolve_base_full_scan_id_exact_lookup_failure_does_not_fallback( + core, monkeypatch +): + core.cli_config = make_cli_config("--base-commit-sha", "abc123") + core.sdk.fullscans.get.return_value = {} + fallback_calls = [] + monkeypatch.setattr( + Core, + "find_baseline_scan_for_ancestor", + lambda *args, **kwargs: fallback_calls.append((args, kwargs)), + ) + + with pytest.raises(APIFailure): + core.resolve_base_full_scan_id(make_full_scan_params()) + assert fallback_calls == [] + + +def test_resolve_base_full_scan_id_ancestor_fallback_skips_temporary_scans(core, monkeypatch): + """A tmp scan on an ancestor is not a usable baseline either""" + core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha") + core.sdk.fullscans.get.side_effect = [ + {"results": [], "nextPage": None}, + {"results": [{"id": "tmp-scan", "commit_hash": "ancestor-1", "tmp": True}], "nextPage": None}, + ] + monkeypatch.setattr( + Core, "first_parent_commits", + lambda self, sha, depth: ["unscanned-sha", "ancestor-1"], + ) + + with pytest.raises(SystemExit): + core.resolve_base_full_scan_id(make_full_scan_params()) + + +def test_resolve_base_full_scan_id_ancestor_fallback_needs_local_history(core, monkeypatch): + """Without local history there is nothing to match scans against""" + core.cli_config = make_cli_config("--base-commit-sha", "unscanned-sha") + core.sdk.fullscans.get.side_effect = [ + {"results": [], "nextPage": None}, + {"results": [{"id": "ancestor-scan", "commit_hash": "ancestor-2"}], "nextPage": None}, + ] + monkeypatch.setattr(Core, "first_parent_commits", lambda self, sha, depth: []) + + with pytest.raises(SystemExit): + core.resolve_base_full_scan_id(make_full_scan_params()) + + def test_resolve_base_full_scan_id_commit_sha_not_found_exits(core): """A --base-commit-sha with no scan is a hard error (exit_code_on_api_error)""" core.cli_config = make_cli_config("--base-commit-sha", "abc123") @@ -309,10 +587,18 @@ def test_empty_alerts_preserved(core): def test_repository_head_baseline_log(core, caplog): + core.sdk.fullscans.get.return_value = { + "results": [{"id": "standard-head"}], + "nextPage": None, + } + with caplog.at_level("INFO", logger="socketdev"): - assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "standard-head" - assert 'Baseline selected: source=repository-head scan_id="head"' in caplog.messages + assert ( + 'Baseline selected: source=repository-head scan_id="standard-head"' + in caplog.messages + ) def test_explicit_scan_baseline_log(core, caplog): diff --git a/tests/unit/test_gitlab_format.py b/tests/unit/test_gitlab_format.py index 4a1cf0c1..b817e341 100644 --- a/tests/unit/test_gitlab_format.py +++ b/tests/unit/test_gitlab_format.py @@ -86,7 +86,10 @@ def test_identifier_extraction_with_cve(self): type="vulnerability", severity="critical", title="Known CVE", - props={"cve": ["CVE-2024-5678", "CVE-2024-9012"]}, + props={ + "cveId": ["CVE-2024-5678", "CVE-2024-9012"], + "ghsaId": "GHSA-1234-5678-9012", + }, pkg_type="npm", key="test-key", purl="pkg:npm/vulnerable-pkg@2.0.0" @@ -96,15 +99,17 @@ def test_identifier_extraction_with_cve(self): report = Messages.create_security_comment_gitlab(diff) vuln = report["vulnerabilities"][0] - # Should have socket_alert identifier + 2 CVE identifiers - assert len(vuln["identifiers"]) >= 3 + # Should have socket_alert identifier + CVE and GHSA identifiers + assert len(vuln["identifiers"]) == 4 cve_identifiers = [i for i in vuln["identifiers"] if i["type"] == "cve"] assert len(cve_identifiers) == 2 assert any(i["value"] == "CVE-2024-5678" for i in cve_identifiers) assert any(i["value"] == "CVE-2024-9012" for i in cve_identifiers) + ghsa_identifiers = [i for i in vuln["identifiers"] if i["type"] == "ghsa"] + assert ghsa_identifiers[0]["value"] == "GHSA-1234-5678-9012" def test_identifier_extraction_with_single_cve_string(self): - """Test single CVE identifier as string""" + """Legacy CVE property remains supported""" diff = Diff() diff.id = "test-scan-id" diff.diff_url = "https://socket.dev/test" @@ -129,8 +134,63 @@ def test_identifier_extraction_with_single_cve_string(self): assert len(cve_identifiers) == 1 assert cve_identifiers[0]["value"] == "CVE-2024-1111" + def test_identifier_extraction_deduplicates_legacy_and_current_cve_fields(self): + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Duplicate CVE", + props={"cve": "CVE-2024-1111", "cveId": "CVE-2024-1111"}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + assert [item["value"] for item in identifiers].count("CVE-2024-1111") == 1 + + def test_identifier_extraction_supports_snake_case_props(self): + """Alerts can reach Issue.props with snake_case vulnerability ids""" + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Snake case ids", + props={"cve_id": "CVE-2024-2222", "ghsa_id": "GHSA-2222-3333-4444"}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + by_type = {item["type"]: item for item in identifiers} + assert by_type["cve"]["value"] == "CVE-2024-2222" + assert by_type["ghsa"]["value"] == "GHSA-2222-3333-4444" + + def test_identifier_extraction_ignores_unusable_prop_values(self): + """Malformed props must not take down the whole report""" + issue = Issue( + pkg_name="vulnerable-pkg", + pkg_version="2.0.0", + type="vulnerability", + severity="high", + title="Malformed props", + props={"cveId": 1234, "ghsaId": None}, + pkg_type="npm", + key="test-key", + purl="pkg:npm/vulnerable-pkg@2.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + assert [item["type"] for item in identifiers] == ["socket_alert"] + def test_dependency_chain_handling_transitive(self): - """Test transitive dependency path is captured""" + """Directness comes from the package record, not from parsing a path string""" diff = Diff() diff.id = "test-scan-id" diff.diff_url = "https://socket.dev/test" @@ -144,6 +204,7 @@ def test_dependency_chain_handling_transitive(self): introduced_by=[ ["top-level > intermediate > transitive-dep", "package.json"] ], + direct=False, pkg_type="npm", key="test-key", purl="pkg:npm/transitive-dep@1.5.0" @@ -171,6 +232,7 @@ def test_dependency_chain_handling_direct(self): introduced_by=[ ["direct-dep", "package.json"] ], + direct=True, pkg_type="npm", key="test-key", purl="pkg:npm/direct-dep@3.0.0" @@ -182,6 +244,62 @@ def test_dependency_chain_handling_direct(self): assert vuln["location"]["dependency"]["direct"] is True + def test_location_file_falls_back_to_the_package_manifest(self): + """A package with no introduced_by chain still knows its own manifest""" + issue = Issue( + pkg_name="transitive-dep", + pkg_version="1.5.0", + type="malware", + severity="critical", + title="Malware Found", + introduced_by=[], + manifest_files=[{"file": "services/api/pom.xml"}], + direct=False, + pkg_type="maven", + key="test-key", + purl="pkg:maven/org.example/transitive-dep@1.5.0", + ) + + location = Messages.extract_location_gitlab(issue) + + assert location["file"] == "services/api/pom.xml" + assert location["dependency"]["direct"] is False + + def test_location_file_is_unknown_only_when_nothing_is_known(self): + """The unknown placeholder is a last resort, not the first answer""" + issue = Issue( + pkg_name="orphan", + pkg_version="1.0.0", + type="malware", + severity="critical", + title="Malware Found", + introduced_by=[], + pkg_type="npm", + key="test-key", + purl="pkg:npm/orphan@1.0.0", + ) + + assert Messages.extract_location_gitlab(issue)["file"] == "unknown" + + def test_identifier_url_is_omitted_rather_than_null(self): + """GitLab types identifier url as a string; null fails schema validation""" + issue = Issue( + pkg_name="nourl-pkg", + pkg_version="1.0.0", + type="malware", + severity="critical", + title="Malware", + pkg_type="npm", + key="test-key", + purl="pkg:npm/nourl-pkg@1.0.0", + ) + + identifiers = Messages.extract_identifiers_gitlab(issue) + + # An absent key is correct; a present-but-null value is what breaks validation. + assert all("url" not in i or i["url"] for i in identifiers) + assert "url" not in identifiers[0] + def test_severity_mapping(self): """Test all Socket severities map to GitLab severities""" severity_tests = [ diff --git a/uv.lock b/uv.lock index 446a9241..469230d6 100644 --- a/uv.lock +++ b/uv.lock @@ -1293,7 +1293,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.8.2" +version = "2.8.3" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From a96f0549d048d1cdbb0613a7e81d8af072632d26 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:37:07 -0400 Subject: [PATCH 18/21] Preserve pull request context, full-scan SCM branch pipelines, and gate ignore commands (#302) * feat(output): show patched versions in security findings * feat(ci): preserve pull request context in scan metadata * chore(release): bump version to 2.9.0 2.8.0 and 2.8.1 shipped from main while this branch was open, so the original 2.8.0 bump here is dead. This branch changes the behavior of existing flags rather than only fixing them -- --pr-number gains auto-detection, --scm github|gitlab implies --integration, and SCM branch pipelines switch from diff scans to full scans and stop returning a blocking exit code -- so it takes the minor bump per the repo's semver standard, not a patch. Co-Authored-By: Claude Opus 5 (1M context) * refactor: share one git remote parser between Buildkite consumers The GitHub comment adapter and pull request link construction each parsed BUILDKITE_REPO independently. Consolidate on socketsecurity.core.git_remote, which also reports the remote host (needed for self-hosted GitHub Enterprise and GitLab) and preserves nested GitLab subgroup paths. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): apply the pull request link to an already-compared scan pair external_href is only honored while a diff scan is being created, so a re-run over the same before/after pair left the Dashboard report with no link back to its pull request. Send on_duplicate=update alongside it, which applies the link to the existing diff scan and answers 200 with the same envelope as a create. The 409-and-resolve path is retained for runs with no pull request context and for deployments that predate on_duplicate=update. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): make per-alert ignores round trip * fix(comments): preserve dependency change types * fix(ci): use full scans outside pull requests * docs: update release notes for comment fixes * fix(ci): restrict SCM diffs to pull requests * fix(scans): keep the package list on full scans create_full_scan_with_report_url only fetched SBOM data when an alert-bearing output format was enabled, so --generate-license and --legal-format fossa saw an empty diff.packages and wrote an attribution file with zero packages. That is the list they enumerate, as _requires_unchanged_artifacts already documents for the comparison path. Fetch the SBOM for them too, and enrich it through the PURL endpoint the way the comparison path does. The full scan's package map is keyed by artifact id while get_license_text_via_purl keys off ecosystem/name@version, so pass a purl-keyed view over the same Package objects. Alert consolidation stays behind its own gate, so an alert-only run does not pay for the license lookup and a license-only run does not build an alert list. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): keep branch pipelines out of pull request handling Two ways an SCM branch build could still be treated like a pull request: Buildkite always sets BUILDKITE_PULL_REQUEST, to the string "false" on a branch build, so the documented --pr-number "$BUILDKITE_PULL_REQUEST" form delivers a truthy non-numeric value. resolve_pull_request_context read it as no PR but only wrote the normalized number back when one was found, so GithubConfig still saw "false", check_event_type returned "diff" for a push, and comment lookups went to issues/false/comments. Canonicalize config.pr_number before any adapter reads it. A branch run creating a full scan then blocked on diff.new_alerts, which a full scan cannot fill meaningfully: empty with no alert-bearing output format enabled, and every alert in the scan rather than the newly introduced ones with one. The exit code therefore depended on which output format was requested. Treat these runs the way a run with no supported manifest files is already treated and skip blocking, leaving pull request pipelines to enforce policy. Move the scan-type decision into create_scm_scan, which returns the diff and whether it came from a comparison, so the branch is exercised by tests rather than only its predicate. Document both the scan-type table and the blocking consequence in the CI/CD guide. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): stop reading an npm scope as an ecosystem Ignore matching strips the ecosystem off a command so an ecosystem-qualified reply still matches the bare package name parsed out of a start-socket-alert marker. It stripped any leading path segment, and a scope sits in the same position, so "ignore @types/node@*" also suppressed alerts for a package named node. Only strip a leading segment that cannot be a scope. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): keep the diff badge where artwork exists Labelling every dependency overview row with bold text dropped the badge from added rows, which is the only category the overview rendered before. The badge host publishes diff-added.svg and diff-updated.svg but nothing for removed or replaced, so look the badge up per change type and fall back to the text label only where there is no image to render. Co-Authored-By: Claude Opus 5 (1M context) * refactor(config): scope the config-file defaults dict to its block normalized_defaults has no reader outside the branch that fills it. Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the release notes for branch pipeline scans The entry still described the intermediate behavior where explicit diff flags opted a non-PR run into comparison mode; the detected event type has been authoritative since that was reverted. Record the blocking and license consequences alongside it, plus the ignore and overview fixes. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): stop legacy comment updates crashing on scoped names process_original_security_comment split the package cell on every "@", so a scoped name carrying its own "@" unpacked into three values and raised an uncaught ValueError. Same bug class this branch already fixed one function over in process_updated_security_comment, just left in its sibling. Split from the right, and pass the ecosystem through as pkg_type rather than pre-concatenating it onto the package name. That makes the two comment formats agree: both now accept an ignore command for a scoped package in either the ecosystem-qualified or the bare form, where the legacy path previously matched only the qualified one. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): require write access to ignore an alert An @SocketSecurity ignore command suppresses a security finding, but the CLI honored one from any commenter. Comment.author_association was carried on the dataclass and never read, so nothing on the path from comment to suppressed alert asked whether the author could push to the repository. A drive-by ignore-all on an open pull request silenced every finding on it. Gate the ignore bucket in check_for_socket_comments, the one place every consumer goes through. A rejected command is logged with its author and is also absent from the ignore telemetry, which should record what was acted on. GitHub returns author_association with every comment, so the check is free and definitive: OWNER, MEMBER and COLLABORATOR only. GitLab notes carry no equivalent, so project membership is read once per run, and only when an ignore command is actually present. members/all is used rather than a per-user lookup because it answers non-membership with a 200 and an absent id -- CliClient collapses every HTTP error into APIFailure without a status code, so a per-user 404, exactly the outsider case, would be indistinguishable from a token that cannot read the endpoint and would have to fail open. When membership genuinely cannot be read -- a CI_JOB_TOKEN typically cannot -- the command is honored and a warning names the author, so this does not silently break pipelines already relying on ignore commands. Documented alongside the token requirement to get enforcement. Co-Authored-By: Claude Opus 5 (1M context) * fix(ci): validate CI-supplied server URLs before building a link GITHUB_SERVER_URL and CI_SERVER_URL were composed into the pull request link verbatim, while the sibling repository URLs read from the same environment already went through a scheme/netloc check. The result is sent to the API as a diff scan's external_href, so route all of them through one validator. Standard runners set these themselves, so this is defense in depth rather than a live hole. An unusable value now falls back to github.com for GitHub; GitLab has no public default host, so the link is dropped and the scan keeps its number. Co-Authored-By: Claude Opus 5 (1M context) * docs: correct the add_purl_capabilities docstring The loop covers updated_packages as well as new_packages; the docstring still described only the latter. Co-Authored-By: Claude Opus 5 (1M context) * docs: record the review fixes in the 2.9.0 release notes Co-Authored-By: Claude Opus 5 (1M context) * docs: rewrite branch comments for the reader, not the author Sweep of every comment this branch adds, against the fourth-wall skill: - A test docstring stated the scan type "(since 2.8.0)", which was already wrong after the renumber to 2.9.0 and would rot again on the next one. Version stamps in comments describe a debut rather than the behavior. - Two docstrings narrated the failure the old parser produced instead of the invariant that makes rsplit correct. A scoped name carrying its own "@" is the whole reason; the traceback it used to raise is not. - The "do NOT use on_duplicate=redirect" landmine was explained twice, in full, at both call sites. Kept at the 409 fallback, where the temptation to add it lives; the create site now just says what update does. - A test section header justified its own design to a reviewer ("swapping the call back ... fails them"). Restated as what the test actually pins. - "out of this branch" in the remote-URL regex reads as a git branch in this repo; it means the regex case. 642 passed, ruff clean. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): parse legacy alert rows defensively Each row of the legacy comment table was unpacked through four consecutive splits with no bounds checks: five cells, then the markdown link, then the ecosystem, then the version. The row comes back from the provider's API, so a cell carrying an extra "|", a package cell that is not a link, or a name with no version raised out of the comment rewrite and ended the run before it reported status. A scoped package name in Socket's own table reached the same place with nobody doing anything unusual. parse_alert_table_row returns None instead of raising for any row it cannot read, and an unreadable row keeps its alert reported -- the safe direction, since a row that cannot be parsed cannot be evaluated against the ignore commands either. Also pins change-type preservation against the real artifact conversion rather than a stubbed field. The existing test assigned diffType by hand, so it would have passed whether or not the conversion populated it; the new one runs real DiffArtifact objects through both response shapes, and fails if the field is dropped. Co-Authored-By: Claude Opus 5 (1M context) * feat(comments): add --ignore-authorization The write-access gate had no escape hatch, and its GitLab behavior when project membership cannot be read -- honor the command with a warning -- was the one deliberate weakness in it. Both are now a choice: enforce (default) require write access; honor with a warning where the provider cannot report it strict reject in that case instead off perform no check enforce closes the hole wherever the provider can answer without breaking a pipeline whose token cannot read membership, which is why it is the default. strict closes it everywhere and will fail those pipelines. off restores the prior behavior for anyone who needs comment-driven ignores from unverified authors. Threaded through the adapter constructors as a keyword argument with a default, so existing call sites keep working. With off the predicate is never handed to check_for_socket_comments at all, so nothing is filtered and no rejection is logged, rather than a gate that silently approves everything. Co-Authored-By: Claude Opus 5 (1M context) * fix(comments): escape repository-derived values when rendering comments Manifest paths and sources are file paths inside the scanned repository, so anyone who can open a pull request controls them: a directory named with link or tag syntax, holding a manifest, put that markup into a comment posted by a trusted integration. Alert text comes from the API. Neither is markup the CLI authored, so both are escaped where they are interpolated -- text nodes with html.escape, href and src with quotes escaped too, since an unescaped quote closes the attribute and everything after it reads as more attributes. The alert markers are the exception: they are read back verbatim when a comment is rewritten, so they cannot be escaped without breaking the ignore round trip. They instead lose only the ability to terminate the comment early. plain and raw styles are untouched. Slack, Jira and the console do not render HTML, and escaping there would show entities to a human. Round-trip tests render a comment with each hostile path and feed it back through the parser, because the renderer and the parser are two halves of one loop: an escaping choice the parser cannot read would silently stop ignores working. Co-Authored-By: Claude Opus 5 (1M context) * fix(gitlab): make the authentication fallback actually run _get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of the token, and retries once under the other scheme on a 401 so a wrong guess does not fail the run. That retry has never executed. Three things had to line up and none of them did: - The retry caught requests.exceptions.HTTPError, but CliClient translates every requests error into APIFailure before it gets there. - CliClient discarded the HTTP status, so even a caught failure could not be identified as a 401. is_transient_error was equally blind for the same reason. - There are two APIFailure classes -- the CLI's own and the SDK's -- and they were independent Exception subclasses. CliClient raises the CLI's; every handler in socketsecurity.core imports the SDK's. None of those eight handlers has ever caught a CliClient failure. The CLI's APIFailure now subclasses the SDK's, so a handler written against either catches both, and the status code travels with the exception. The two tests covering the fallback were skipped rather than fixed, with a reason that no longer described the failure -- the constructor they blamed is used by the two passing tests in the same file. They now drive the exception the way CliClient actually raises it, and fail if any of the three links above is broken again. Co-Authored-By: Claude Opus 5 (1M context) * fix(review): address authorization and full-scan findings * fix(comments): close ignore authorization gaps --------- Co-authored-by: Claude Opus 5 (1M context) --- CHANGELOG.md | 121 +++++++- docs/ci-cd.md | 147 ++++++++- docs/cli-reference.md | 38 ++- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- socketsecurity/config.py | 50 ++- socketsecurity/core/__init__.py | 163 +++++++--- socketsecurity/core/alert_selection.py | 2 + socketsecurity/core/classes.py | 14 + socketsecurity/core/cli_client.py | 7 +- socketsecurity/core/exceptions.py | 14 +- socketsecurity/core/git_remote.py | 43 +++ socketsecurity/core/messages.py | 213 +++++++++---- socketsecurity/core/pull_request.py | 144 +++++++++ socketsecurity/core/scm/github.py | 112 +++++-- socketsecurity/core/scm/gitlab.py | 149 +++++++-- socketsecurity/core/scm_comments.py | 123 ++++++-- socketsecurity/output.py | 12 + socketsecurity/socketcli.py | 191 ++++++++++-- tests/core/test_diff_generation.py | 49 ++- tests/core/test_diff_scan_polling.py | 57 +++- tests/core/test_full_scan_outputs.py | 128 ++++++++ tests/unit/test_cli_config.py | 54 ++++ tests/unit/test_client.py | 53 ++++ tests/unit/test_dependency_overview.py | 23 ++ tests/unit/test_disable_ignore.py | 37 +++ tests/unit/test_git_remote.py | 41 +++ tests/unit/test_gitlab_auth_fallback.py | 55 ++-- tests/unit/test_ignore_authorization.py | 267 ++++++++++++++++ tests/unit/test_ignore_telemetry_filtering.py | 18 ++ tests/unit/test_messages.py | 81 +++++ tests/unit/test_pr_comment_rendering.py | 201 ++++++++++++ tests/unit/test_pull_request_context.py | 293 ++++++++++++++++++ tests/unit/test_socketcli.py | 96 ++++++ uv.lock | 2 +- workflows/buildkite.yml | 21 +- 36 files changed, 2747 insertions(+), 276 deletions(-) create mode 100644 socketsecurity/core/git_remote.py create mode 100644 socketsecurity/core/pull_request.py create mode 100644 tests/core/test_full_scan_outputs.py create mode 100644 tests/unit/test_git_remote.py create mode 100644 tests/unit/test_ignore_authorization.py create mode 100644 tests/unit/test_messages.py create mode 100644 tests/unit/test_pull_request_context.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8bcdfd..cd66c2ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,125 @@ # Changelog +## 2.9.0 + +### Added: patched versions in human-readable security output + +- The native console alert table now includes a `Patched Version` column, + populated from `props.firstPatchedVersionIdentifier` when the API provides it. +- GitHub pull request and GitLab merge request security comments now show the + patched version in each applicable alert's details. + +### Fixed: CLI scans retain pull request context in the Socket Dashboard + +- Pull request numbers are detected from standard GitHub Actions, GitLab CI, + and Azure Pipelines environments when `--pr-number` is not supplied. An + explicitly supplied value, including `0`, remains authoritative. +- The Buildkite workflow and CI/CD guide now forward `BUILDKITE_PULL_REQUEST` + explicitly and document provider selection for Dashboard PR association. With + `--integration github` or `--integration gitlab`, the repository slug and host + for the link are read from `BUILDKITE_REPO`, covering self-hosted installations. +- `--scm github` and `--scm gitlab` now imply the matching scan integration + unless `--integration` is explicitly supplied. +- Diff scans include the detected pull request or merge request URL as their + external link, allowing Dashboard reports to retain their CI change context. + Re-running a comparison over an already-compared scan pair now applies the + link to the existing diff scan instead of leaving that report unassociated. +- A `--pr-number` value that is not a positive integer is now normalized to `0` + before the GitHub adapter reads it, so Buildkite's `false` on a branch build no + longer makes that build look like a pull request event. + +### Changed: GitHub and GitLab branch pipelines create full scans + +- With `--scm github` or `--scm gitlab`, only pull request and merge request + events create diff scans. Every other pipeline, including default-branch + pushes, creates a full scan. The detected event type is authoritative: + `--enable-diff` and `--ignore-commit-files` no longer opt an SCM branch run + into comparison mode. +- Those runs no longer set a blocking exit code. A full scan has no baseline, so + it cannot distinguish newly introduced alerts from pre-existing ones; the CLI + now behaves as if `--disable-blocking` was supplied, matching how it already + treats a run with no supported manifest files. Pull request and merge request + pipelines are unaffected and still block. +- `--generate-license` and `--legal-format fossa` fetch the package list on this + path, so attribution files generated from a branch pipeline are complete rather + than empty. +- Console-only full scans link to the Socket report and state that findings were + not fetched for console output instead of presenting an empty local alert list + as "No issues found." +- License enrichment keeps the package namespace in PURL requests and response + matching, so scoped npm packages and namespaced Maven packages receive their + license details. + +### Changed: `@SocketSecurity ignore` requires write access + +- An ignore command suppresses a security alert, but the CLI honored one from any + commenter, including a drive-by comment from someone with no access to the + repository. Commands are now accepted only from an author with write access. +- On GitHub this is read from the effective repository permission and cached per + commenter for the run. Write, maintain, or admin access is required; relationship + labels such as `MEMBER` and `COLLABORATOR` are not treated as permissions. +- A 404 from GitHub's collaborator-permission endpoint is treated as a definitive + denial rather than an unreadable permission, so the default `enforce` policy does + not honor ignore commands from users outside the repository. +- GitLab notes carry no equivalent field, so project membership is read once per + run (only when an ignore command is present) and Developer or above is required. + If that lookup cannot be answered โ€” a `CI_JOB_TOKEN` generally cannot read the + members API โ€” the command is still honored and a warning names the author, so + enabling this does not silently break pipelines that relied on ignore commands. + Use a `GITLAB_TOKEN` with API read access to get enforcement. +- A rejected command is logged and is also absent from the ignore telemetry, which + records what was acted on. No acknowledgement reaction is added to a comment that + was not honored. +- `--ignore-authorization` selects the policy: `enforce` (default) requires write + access and honors the command with a warning where the provider cannot report it, + `strict` rejects it in that case instead, and `off` performs no check. + +### Fixed: GitLab authentication fallback never ran + +- When a GitLab token's type cannot be inferred from its shape, the CLI guesses + between Bearer and PRIVATE-TOKEN and retries once under the other scheme on a + 401. That retry never happened: the retry caught `requests.exceptions.HTTPError`, + but the HTTP client translates every request error into `APIFailure` first, so a + misclassified token failed the run instead of falling back. +- API failures raised by the CLI's HTTP client now carry their HTTP status code. + Without it a 401 was indistinguishable from any other failure, and + `is_transient_error` could not classify one either. +- The CLI's `APIFailure` now subclasses the SDK exception of the same name. They + were independent types, so an `except APIFailure` importing the SDK's โ€” which is + what every handler in `socketsecurity.core` does โ€” did not catch a failure raised + by the HTTP client. + +### Fixed: pull request and merge request comment accuracy + +- Per-alert ignore instructions now use ecosystem-qualified package names and + accept scoped packages while remaining compatible with older bare-name replies. + A leading npm scope is no longer mistaken for an ecosystem, so + `ignore @types/node@*` no longer also ignores the package named `node`. +- Ignore telemetry uses the same package matcher as alert suppression, so legacy + bare-name commands generate an event for the alert they suppress. +- Dependency overviews preserve added, updated, removed, and replaced package + classifications instead of presenting updates as new dependencies. Added and + updated rows keep their diff badge; removed and replaced, which have no + published badge, use a text label. +- Shared security comment copy no longer describes GitLab merge request output + as Socket for GitHub. +- Updating a security comment in the legacy table format no longer raises on a + malformed row. Each row was unpacked through four consecutive splits with no + bounds checks, so a cell carrying an extra `|`, a package cell that is not a + markdown link, or a name with no version ended the run before it reported + status โ€” and a scoped package name in Socket's own table was enough to trigger + it. Rows are now parsed defensively, and a row that cannot be read keeps its + alert reported. Ignore commands for a scoped package are accepted there in both + the ecosystem-qualified and bare forms. +- Server URLs read from `GITHUB_SERVER_URL` and `CI_SERVER_URL` are validated as + http(s) URLs before being composed into a diff scan's external link, matching + the check already applied to the other repository URLs read from CI. +- Repository-derived values are escaped before they are rendered into a pull + request or merge request comment. Manifest paths and sources are file paths from + the scanned repository, and alert text comes from the API; neither is markup the + CLI authored, so both are now escaped at the point they are interpolated. The + alert markers can no longer be terminated early by a package name. Slack, Jira + and console output are unchanged, since none of them render HTML. ## 2.8.3 ### Fixed: GitLab report serialization and workspace baselines @@ -26,7 +146,6 @@ repository, and default branch, including when no workspace is supplied. A baseline lookup that fails is reported as an API error instead of resolving to an empty baseline, and temporary scans are skipped when selecting one. - ## 2.8.2 ### Changed: bump pinned @coana-tech/cli to 15.10.41 diff --git a/docs/ci-cd.md b/docs/ci-cd.md index 2051a691..253c27ca 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -2,6 +2,10 @@ Use this guide for pipeline-focused CLI usage across platforms. +The shell commands in the recommended patterns are CI-provider neutral. Buildkite +pipeline equivalents and provider-specific considerations are called out alongside +the relevant guidance below. + ## Recommended patterns ### Dashboard-style reachable SARIF @@ -27,6 +31,27 @@ socketcli \ --strict-blocking ``` +### Buildkite: retain SARIF as a build artifact + +Either recommended pattern can run directly in a Buildkite command step. When the +scan writes SARIF, add +[`artifact_paths`](https://buildkite.com/docs/pipelines/configure/artifacts#upload-artifacts-with-a-command-step) +so developers can download the report from the build after the command finishes: + +```yaml +steps: + - label: ":socket: Socket reachable diff" + command: | + socketcli \ + --reach \ + --sarif-file results.sarif \ + --sarif-scope diff \ + --sarif-reachability reachable \ + --strict-blocking + artifact_paths: + - "results.sarif" +``` + ## Config file usage in CI Use `--config .socketcli.toml` or `--config .socketcli.json` to keep pipeline commands small. @@ -60,6 +85,9 @@ Equivalent JSON: } ``` +The Buildkite examples below use the same checked-in `.socketcli.toml` file; no +Buildkite-specific config-file format is required. + ## Platform examples ### GitHub Actions @@ -306,14 +334,33 @@ initial timeout signal or 137 if `SIGKILL` is involved. ### Buildkite +This example assumes a GitHub-hosted repository. Change +`SOCKET_SCM_INTEGRATION` to `gitlab` for a GitLab-hosted repository, or `api` +when provider association is not wanted. The doubled dollar signs defer +Buildkite variable expansion until the command runs on an agent. + ```yaml +env: + SOCKET_SCM_INTEGRATION: "github" + steps: - label: "Socket scan" - command: "socketcli --config .socketcli.toml --target-path ." - env: - SOCKET_SECURITY_API_TOKEN: "${SOCKET_SECURITY_API_TOKEN}" + command: | + socketcli \ + --config .socketcli.toml \ + --target-path . \ + --integration "$${SOCKET_SCM_INTEGRATION:-api}" \ + --pr-number "$${BUILDKITE_PULL_REQUEST:-0}" + secrets: + - SOCKET_SECURITY_API_TOKEN ``` +The `secrets` block expects a +[Buildkite secret](https://buildkite.com/docs/pipelines/security/secrets/buildkite-secrets) +named `SOCKET_SECURITY_API_TOKEN`. If your organization uses an external secrets +plugin or an agent hook instead, remove that block and inject the same environment +variable through your existing mechanism. Do not store the token in pipeline YAML. + The CLI reads Buildkite's native `BUILDKITE_COMMIT`, `BUILDKITE_BRANCH`, `BUILDKITE_PULL_REQUEST`, and `BUILDKITE_PULL_REQUEST_BASE_BRANCH` variables. For pull-request builds, ensure the checkout contains the base branch and the @@ -321,11 +368,12 @@ checked-out head commit. The CLI uses those local refs first and performs a targeted fetch only when a required ref or its comparison history is missing; it does not fetch every remote ref and tag during startup. -When `--scm github` is used from Buildkite, the CLI also derives GitHub comment -context from `BUILDKITE_REPO`, `BUILDKITE_BUILD_CHECKOUT_PATH`, and the variables -above. Set `GH_API_TOKEN` to a GitHub token with the required repository access. -GitHub Enterprise users should also set `GITHUB_API_URL`; GitHub.com defaults to -`https://api.github.com`. +When `--scm github` is used from Buildkite, the CLI also posts GitHub PR comments. +It identifies the repository from `BUILDKITE_REPO` and takes the rest of the build +context from `BUILDKITE_BUILD_CHECKOUT_PATH` and the variables above โ€” see +[Buildkite PR context](#buildkite-pr-context). Set `GH_API_TOKEN` to a GitHub token +with the required repository access. GitHub Enterprise users should also set +`GITHUB_API_URL`; GitHub.com defaults to `https://api.github.com`. #### Merge-base baselines in Buildkite (dynamic pipelines) @@ -389,6 +437,18 @@ socket_scan: SOCKET_SECURITY_API_TOKEN: $SOCKET_SECURITY_API_TOKEN ``` +### Azure Pipelines + +```yaml +- script: | + socketcli \ + --integration azure \ + --enable-diff \ + --target-path "$(Build.SourcesDirectory)" + env: + SOCKET_SECURITY_API_TOKEN: $(SOCKET_SECURITY_API_TOKEN) +``` + ### Bitbucket Pipelines ```yaml @@ -399,6 +459,69 @@ pipelines: - socketcli --config .socketcli.toml --target-path . ``` +## Scan type by pipeline + +With `--scm github` or `--scm gitlab`, the detected event decides the scan type: + +| Event | Scan | Blocks the build | +|:------|:-----|:-----------------| +| Pull request / merge request | Diff scan against the repository's baseline | Yes, on newly introduced alerts | +| Any other pipeline, including default-branch pushes | Full scan | No | + +A full scan has no baseline, so it cannot tell a newly introduced alert from one +that was already there. Rather than block on a number that would mean something +different depending on which output format was enabled, those runs behave as if +`--disable-blocking` was supplied and report through the Dashboard instead. This +matches how the CLI already treats a run with no supported manifest files. + +The event type is authoritative once `--scm` is set: `--enable-diff` and +`--ignore-commit-files` do not turn a branch pipeline into a comparison. To diff +a branch build, drop `--scm` and use `--enable-diff` with `--integration`, which +runs the comparison without the PR comment adapter. + +`--generate-license` and `--legal-format fossa` work on both paths; a full scan +fetches the package list for them. + +## Pull request and Dashboard association + +The CLI sends the resolved pull request number with each full scan and attaches +the pull request URL to diff scans so the Socket Dashboard can associate the +report with its originating change. If `--pr-number` is supplied, it wins; +passing `--pr-number 0` explicitly disables automatic association. Any value that +is not a positive integer, including Buildkite's `false`, means no pull request. + +Without an explicit value, the CLI recognizes: + +- GitHub Actions: `PR_NUMBER`, then the PR number in `GITHUB_REF`. +- GitLab CI: `CI_MERGE_REQUEST_IID`. +- Azure Pipelines: `SYSTEM_PULLREQUEST_PULLREQUESTNUMBER` for GitHub-hosted + repositories, otherwise `SYSTEM_PULLREQUEST_PULLREQUESTID` for Azure Repos. + +### Buildkite PR context + +Buildkite is SCM-provider neutral, so the CLI does not infer a provider or consume +its PR variable automatically. Pass Buildkite's +[`BUILDKITE_PULL_REQUEST`](https://buildkite.com/docs/pipelines/configure/environment-variables#BUILDKITE_PULL_REQUEST) +value to +`--pr-number` and identify the repository host with `--integration`, as shown in +the Buildkite platform example above. Buildkite sets `BUILDKITE_PULL_REQUEST` to +`false` outside PR builds; the CLI treats that value as no PR. + +Use `--integration github` for GitHub-hosted repositories and `--integration gitlab` +for GitLab-hosted ones. The CLI identifies the repository from +[`BUILDKITE_REPO`](https://buildkite.com/docs/pipelines/configure/environment-variables#BUILDKITE_REPO), +taking both the slug and the host from it, so github.com, GitLab.com, and self-hosted +installations all build a correct pull request or merge request link without extra +configuration. That same value identifies the repository for GitHub PR comments when +`--scm github` is set. `CI_PROJECT_URL` still overrides the derived GitLab project URL. +Keep `--scm api` unless you also intend to configure an existing GitHub or GitLab +comment adapter and its provider token. + +`--scm github` and `--scm gitlab` also imply the matching scan integration for +Dashboard metadata unless `--integration` was explicitly supplied. PR comments +remain limited to the existing GitHub and GitLab SCM adapters; Azure receives +console output and Dashboard association but does not post a PR comment. + ## Workflow templates Prebuilt examples in this repo: @@ -415,3 +538,11 @@ Prebuilt examples in this repo: - `--sarif-grouping alert` currently applies to `--sarif-scope full`. - Diff-based SARIF can validly be empty when there are no matching net-new alerts. - Keep API tokens in secret stores (`SOCKET_SECURITY_API_TOKEN`), not in config files. +- In Buildkite pipeline YAML, follow its + [runtime interpolation](https://buildkite.com/docs/pipelines/configure/environment-variables#runtime-variable-interpolation) + guidance and use `$$` for variables that must expand when the command runs rather + than when the pipeline is uploaded. +- Security findings with `props.firstPatchedVersionIdentifier` show that value in + the console table, including native Buildkite job logs, and in GitHub/GitLab + security comments when that SCM adapter is configured. Findings without a known + patched release leave the console cell blank and omit the comment field. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 2ee42450..ae85a275 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -238,7 +238,7 @@ If you don't want to provide the Socket API Token every time then you can use th | `--repo` | False | *auto* | Repository name in owner/repo format (auto-detected from git remote) | | `--workspace` | False | | The Socket workspace to associate the scan with (e.g. `my-org` in `my-org/my-repo`). See note below. | | `--repo-is-public` | False | False | If set, flags a new repository creation as public. Defaults to false. | -| `--integration` | False | api | Integration type (api, github, gitlab, azure, bitbucket) | +| `--integration` | False | api | Integration type (api, github, gitlab, azure, bitbucket). When omitted, `--scm github` or `--scm gitlab` implies the matching integration. | | `--owner` | False | | Name of the integration owner, defaults to the socket organization slug | | `--branch` | False | *auto* | Branch name (auto-detected from git) | | `--committers` | False | *auto* | Committer(s) to filter by (auto-detected from git commit) | @@ -252,7 +252,7 @@ If you don't want to provide the Socket API Token every time then you can use th #### Pull Request and Commit | Parameter | Required | Default | Description | |:-----------------|:---------|:--------|:-----------------------------------------------| -| `--pr-number` | False | "0" | Pull request number | +| `--pr-number` | False | *auto* | Pull request number. Auto-detected in GitHub Actions, GitLab CI, and Azure Pipelines; explicitly passing `0` disables detection. | | `--commit-message` | False | *auto* | Commit message (auto-detected from git) | | `--commit-sha` | False | *auto* | Commit SHA (auto-detected from git) | | `--base-scan-id` | False | | Full scan ID to diff against, overriding the repository's head scan as the baseline. Mutually exclusive with `--base-commit-sha` | @@ -429,7 +429,8 @@ The launcher can be tuned via the `SOCKET_CLI_COANA_LAUNCHER` environment variab |:-------------------------|:---------|:--------|:----------------------------------------------------------------------| | `--ignore-commit-files` | False | False | Ignore commit files | | `--disable-blocking` | False | False | Non-blocking CI mode: the CLI always exits **0**, even when blocking alerts are present (including with `--strict-blocking`). Also exits 0 on uncaught runtime errors and Socket API failures, so the job is treated as successful while findings and errors are still logged. Takes precedence over `--strict-blocking`. | -| `--disable-ignore` | False | False | Disable support for `@SocketSecurity ignore` commands in PR comments. When set, alerts cannot be suppressed via comments and ignore instructions are hidden from comment output. | +| `--disable-ignore` | False | False | Disable support for `@SocketSecurity ignore` commands in PR comments. When set, alerts cannot be suppressed via comments and ignore instructions are hidden from comment output. See [Who can ignore an alert](#who-can-ignore-an-alert). | +| `--ignore-authorization` | False | enforce | Who may suppress alerts with `@SocketSecurity ignore`. `enforce` requires write access and honors the command with a warning when the provider cannot report it; `strict` rejects it in that case; `off` honors any commenter. See [Who can ignore an alert](#who-can-ignore-an-alert). | | `--strict-blocking` | False | False | Fail on ANY security policy violations (blocking severity), not just new ones. Only works in diff mode. See [Strict Blocking Mode](#strict-blocking-mode) for details. | | `--enable-diff` | False | False | Enable diff mode even when using `--integration api` (forces diff mode without SCM integration) | | `--scm` | False | api | Source control management type | @@ -688,6 +689,37 @@ The CLI uses intelligent default branch detection with the following priority: Both `--default-branch` and `--pending-head` parameters are automatically synchronized to ensure consistent behavior. +## Who can ignore an alert + +`@SocketSecurity ignore /@` and +`@SocketSecurity ignore-all` suppress security findings, so the CLI honors them +only from a commenter with write access to the repository. A command from anyone +else is skipped, logged with the author's name, and the alerts it named stay +reported. `--disable-ignore` turns the feature off entirely. + +| Provider | How access is determined | If it cannot be determined | +|:---------|:-------------------------|:---------------------------| +| GitHub | Effective repository permission, read once per commenter per run. Write, maintain, or admin access is honored. | The command is honored and a warning is logged. | +| GitLab | Project membership, read once per run when an ignore command is present. Developer (30) or above is honored. | The command is honored and a warning is logged. | + +The GitHub check needs a token that can read repository metadata. GitLab notes +carry no permission field, so that check needs a `GITLAB_TOKEN` that can read +`GET /projects/:id/members/all`. A `CI_JOB_TOKEN` generally cannot. + +`--ignore-authorization` decides what happens when access cannot be determined: + +| Value | Verified write access | Access cannot be determined | +|:------|:----------------------|:----------------------------| +| `enforce` (default) | Honored | Honored, with a warning naming the author | +| `strict` | Honored | Rejected | +| `off` | Honored | Honored, no check performed | + +`enforce` closes the hole wherever the provider can answer, without breaking a +pipeline whose token cannot read membership. `strict` closes it everywhere, at the +cost of failing those pipelines. `off` restores the prior behavior and should be +paired with `--disable-ignore` unless you specifically need comment-driven ignores +from unverified authors. + ## GitLab Token Configuration GitLab token/auth behavior and CI examples are documented in [`ci-cd.md`](ci-cd.md). diff --git a/pyproject.toml b/pyproject.toml index 1c30bc61..334ff1b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.8.3" +version = "2.9.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index c5cb4808..ce3e70ab 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.8.3' +__version__ = '2.9.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index ecd2f287..bb2ad009 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -115,6 +115,7 @@ class CliConfig: branch: str = "" committers: Optional[List[str]] = None pr_number: str = "0" + pr_number_explicit: bool = False commit_message: Optional[str] = None default_branch: bool = False target_path: str = "./" @@ -143,6 +144,7 @@ class CliConfig: ignore_commit_files: bool = False disable_blocking: bool = False disable_ignore: bool = False + ignore_authorization: str = "enforce" # Tri-state log-upload preference: True = --upload-logs, False = --no-upload-logs, # None = neither (server-side override decides). upload_logs: Optional[bool] = None @@ -219,6 +221,17 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': parser.set_defaults(**normalized_defaults) args = parser.parse_args(args_list) + integration_explicit = hasattr(args, "integration") + pr_number_explicit = hasattr(args, "pr_number") + + integration_type = getattr(args, "integration", "api") + pr_number = getattr(args, "pr_number", "0") + if ( + not integration_explicit and + integration_type == "api" and + args.scm in ("github", "gitlab") + ): + integration_type = args.scm if args.reach_exclude_paths: logging.warning( @@ -262,7 +275,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'repo': args.repo, 'branch': args.branch, 'committers': args.committers, - 'pr_number': args.pr_number, + 'pr_number': pr_number, + 'pr_number_explicit': pr_number_explicit, 'commit_message': commit_message, 'default_branch': args.default_branch, 'target_path': os.path.expanduser(args.target_path), @@ -292,9 +306,10 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'ignore_commit_files': args.ignore_commit_files, 'disable_blocking': args.disable_blocking, 'disable_ignore': args.disable_ignore, + 'ignore_authorization': args.ignore_authorization, 'upload_logs': args.upload_logs, 'strict_blocking': args.strict_blocking, - 'integration_type': args.integration, + 'integration_type': integration_type, 'pending_head': args.pending_head, 'timeout': args.timeout, 'exit_code_on_api_error': args.exit_code_on_api_error, @@ -519,8 +534,12 @@ def create_argument_parser() -> argparse.ArgumentParser: "--integration", choices=INTEGRATION_TYPES, metavar="", - help="Integration type of api, github, gitlab, azure, or bitbucket. Defaults to api", - default="api" + help=( + "Integration type of api, github, gitlab, azure, or bitbucket. " + "Defaults to api; --scm github/gitlab implies the matching integration " + "when this option is omitted" + ), + default=argparse.SUPPRESS ) integration_group.add_argument( "--owner", @@ -535,13 +554,17 @@ def create_argument_parser() -> argparse.ArgumentParser: "--pr-number", dest="pr_number", metavar="", - help="Pull request number", - default="0" + help=( + "Pull request number. Auto-detected in supported CI environments when omitted; " + "pass 0 explicitly to disable detection" + ), + default=argparse.SUPPRESS ) pr_group.add_argument( "--pr_number", dest="pr_number", - help=argparse.SUPPRESS + help=argparse.SUPPRESS, + default=argparse.SUPPRESS ) pr_group.add_argument( "--commit-message", @@ -703,6 +726,19 @@ def create_argument_parser() -> argparse.ArgumentParser: action="store_true", help="If true, the new scan will be set as the branch's head scan" ) + config_group.add_argument( + "--ignore-authorization", + dest="ignore_authorization", + choices=["enforce", "strict", "off"], + default="enforce", + help=( + "Who may suppress alerts with @SocketSecurity ignore comments. " + "'enforce' (default) requires write access, and honors the command with " + "a warning when the provider cannot report the commenter's access. " + "'strict' rejects the command in that case instead. " + "'off' honors a command from any commenter." + ) + ) config_group.add_argument( "--pending_head", dest="pending_head", diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index c5f3f52c..fe49bceb 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1291,6 +1291,7 @@ def create_full_scan_with_report_url( diff.report_url = f"{base_socket}/{self.config.org_slug}/sbom/{new_full_scan.id}" diff.diff_url = diff.report_url diff.id = new_full_scan.id + diff.is_full_scan = True needs_alerts = ( self.cli_config is not None @@ -1300,30 +1301,43 @@ def create_full_scan_with_report_url( or self.cli_config.enable_sarif ) ) + # --generate-license (and --legal-format fossa, which it gates) enumerates + # diff.packages rather than the alert list, so a full scan has to carry the + # package map even when no alert-bearing output format is enabled. Without + # this, an SCM branch pipeline writes an attribution file with zero packages. + # Keep in sync with _requires_unchanged_artifacts, which lists the same + # consumers for the comparison path. + needs_license_artifacts = ( + self.cli_config is not None and self.cli_config.generate_license + ) - if needs_alerts: - log.info("Output format requires alerts, fetching SBOM data for full scan") + if needs_alerts or needs_license_artifacts: + log.info("Output format requires SBOM data, fetching it for the full scan") sbom_start = time.time() sbom_artifacts_dict = self.get_sbom_data(new_full_scan.id) sbom_artifacts = self.get_sbom_data_list(sbom_artifacts_dict) packages = self._create_packages_dict_without_license_text(sbom_artifacts) + if needs_license_artifacts: + packages = self._add_license_details(packages) diff.packages = packages - all_alerts_collection: Dict[str, List[Issue]] = {} - for package_id, package in packages.items(): - self.add_package_alerts_to_collection( - package=package, - alerts_collection=all_alerts_collection, - packages=packages - ) + if needs_alerts: + all_alerts_collection: Dict[str, List[Issue]] = {} + for package_id, package in packages.items(): + self.add_package_alerts_to_collection( + package=package, + alerts_collection=all_alerts_collection, + packages=packages + ) - consolidated: Set[str] = set() - for alert_key, alerts in all_alerts_collection.items(): - for alert in alerts: - alert_str = f"{alert.purl},{alert.type}" - if (alert.error or alert.warn) and alert_str not in consolidated: - diff.new_alerts.append(alert) - consolidated.add(alert_str) + consolidated: Set[str] = set() + for alert_key, alerts in all_alerts_collection.items(): + for alert in alerts: + alert_str = f"{alert.purl},{alert.type}" + if (alert.error or alert.warn) and alert_str not in consolidated: + diff.new_alerts.append(alert) + consolidated.add(alert_str) + diff.alerts_fetched = True sbom_end = time.time() log.info( @@ -1335,6 +1349,29 @@ def create_full_scan_with_report_url( return diff + def _add_license_details(self, packages: dict[str, Package]) -> dict[str, Package]: + """Populate licenseAttrib/licenseDetails on a full scan's package map. + + get_license_text_via_purl keys off ``ecosystem/name@version`` because that is + what the PURL endpoint echoes back, while a full scan's package map is keyed + by artifact id. Build a purl-keyed view over the same Package objects so the + enrichment lands on the map the caller keeps. + """ + batch_size = self.cli_config.max_purl_batch_size if self.cli_config else 5000 + packages_by_purl = {} + for package in packages.values(): + qualified_name = package.name + if package.namespace: + qualified_name = f"{package.namespace.strip('/')}/{qualified_name}" + packages_by_purl[ + f"{package.type}/{qualified_name}@{package.version}" + ] = package + self.get_license_text_via_purl( + packages_by_purl, + batch_size=batch_size, + ) + return packages + def get_full_scan(self, full_scan_id: str) -> FullScan: """ Get a FullScan object for an existing full scan including sbom_artifacts and packages. @@ -1884,6 +1921,9 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in for result in results: ecosystem = result["type"] name = result["name"] + namespace = (result.get("namespace") or "").strip("/") + if namespace and not name.startswith(f"{namespace}/"): + name = f"{namespace}/{name}" package_version = result["version"] licenseDetails = result.get("licenseDetails") licenseAttrib = result.get("licenseAttrib") @@ -1897,7 +1937,8 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in def get_diff_scan_artifacts( self, head_full_scan_id: str, - new_full_scan_id: str + new_full_scan_id: str, + external_href: Optional[str] = None ) -> DiffArtifacts: """Compare two full scans via the diff-scans endpoints, polling for the result. @@ -1920,6 +1961,8 @@ def get_diff_scan_artifacts( Args: head_full_scan_id: The before/base full scan ID new_full_scan_id: The after/head full scan ID + external_href: Optional pull request or merge request URL to associate + with the diff scan in the Socket Dashboard Returns: DiffArtifacts with the added/removed/unchanged/replaced/updated lists @@ -1929,6 +1972,13 @@ def get_diff_scan_artifacts( "after": new_full_scan_id, "description": f"Socket Security CLI v{__version__} scan comparison", } + if external_href: + create_params["external_href"] = external_href + # external_href is only honored while a diff scan is being created, + # so a re-run over an already-compared scan pair needs + # on_duplicate=update to apply the link to the existing resource. It + # answers 200 with the same {"diff_scan": ...} envelope as a create. + create_params["on_duplicate"] = "update" try: result = self.sdk.diffscans.create_from_ids(self.config.org_slug, create_params) diff_scan = result.get("diff_scan") or {} @@ -1937,11 +1987,13 @@ def get_diff_scan_artifacts( 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. + # Reached when there is no pull request context to attach, and on + # deployments that answer 409 regardless. Do NOT switch this to + # on_duplicate=redirect: 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 explicitly so every result fetch continues through the + # bounded cached polling path below. existing = self.sdk.diffscans.list( self.config.org_slug, params={ @@ -2077,7 +2129,8 @@ def get_added_and_removed_packages( self, head_full_scan_id: str, new_full_scan_id: str, - include_license_details: bool = False + include_license_details: bool = False, + external_href: Optional[str] = None ) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]: """ Get packages that were added and removed between scans. @@ -2110,6 +2163,8 @@ def get_added_and_removed_packages( is retained as an explicit override seam, not wired to the ``--exclude-license-details`` user flag (which still governs the human-facing dashboard report URL). + external_href: Optional pull request or merge request URL to associate + with the primary diff-scan resource Returns: Tuple of (added_packages, removed_packages) dictionaries @@ -2121,7 +2176,8 @@ def get_added_and_removed_packages( try: diff_artifacts = self.get_diff_scan_artifacts( head_full_scan_id, - new_full_scan_id + new_full_scan_id, + external_href=external_href, ) except Exception as error: # SDK error messages can span many lines (path + response headers); the @@ -2237,7 +2293,8 @@ def create_new_diff( save_files_list_path: Optional[str] = None, save_manifest_tar_path: Optional[str] = None, base_paths: Optional[List[str]] = None, - explicit_files: Optional[List[str]] = None + explicit_files: Optional[List[str]] = None, + external_href: Optional[str] = None ) -> Diff: """Create a new diff using the Socket SDK. @@ -2249,6 +2306,8 @@ def create_new_diff( save_manifest_tar_path: Optional path to save manifest files tar.gz archive base_paths: List of base paths for the scan (optional) explicit_files: Optional list of explicit files to use instead of discovering files + external_href: Optional pull request or merge request URL to associate + with the diff scan """ log.debug(f"starting create_new_diff with no_change: {no_change}") if no_change: @@ -2383,7 +2442,8 @@ def create_new_diff( ) = self.get_added_and_removed_packages( head_full_scan_id, new_full_scan.id, - include_license_details=False + include_license_details=False, + external_href=external_href, ) # Separate unchanged packages from added/removed for --strict-blocking support @@ -2447,16 +2507,22 @@ def create_diff_report( alerts_in_removed_packages: Dict[str, List[Issue]] = {} alerts_in_unchanged_packages: Dict[str, List[Issue]] = {} - seen_new_packages = set() - seen_removed_packages = set() + seen_packages = { + "added": set(), + "updated": set(), + "removed": set(), + "replaced": set(), + } for package_id, package in added_packages.items(): purl = self.create_purl(package_id, added_packages) base_purl = f"{purl.ecosystem}/{purl.name}@{purl.version}" - if (not direct_only or package.direct) and base_purl not in seen_new_packages: - diff.new_packages.append(purl) - seen_new_packages.add(base_purl) + change_type = "updated" if package.diffType == "updated" else "added" + target = diff.updated_packages if change_type == "updated" else diff.new_packages + if (not direct_only or package.direct) and base_purl not in seen_packages[change_type]: + target.append(purl) + seen_packages[change_type].add(base_purl) self.add_package_alerts_to_collection( package=package, @@ -2468,9 +2534,11 @@ def create_diff_report( purl = self.create_purl(package_id, removed_packages) base_purl = f"{purl.ecosystem}/{purl.name}@{purl.version}" - if (not direct_only or package.direct) and base_purl not in seen_removed_packages: - diff.removed_packages.append(purl) - seen_removed_packages.add(base_purl) + change_type = "replaced" if package.diffType == "replaced" else "removed" + target = diff.replaced_packages if change_type == "replaced" else diff.removed_packages + if (not direct_only or package.direct) and base_purl not in seen_packages[change_type]: + target.append(purl) + seen_packages[change_type].add(base_purl) self.add_package_alerts_to_collection( package=package, @@ -2595,23 +2663,24 @@ def get_source_data(package: Package, packages: dict) -> list: @staticmethod def add_purl_capabilities(diff: Diff) -> None: """ - Adds capability information to each package in the diff's new_packages list. + Adds capability information to the diff's added and updated packages. + + Both lists are walked because an updated package is still newly present at + its new version, so its capabilities are as relevant as an added one's. Args: diff: Diff object to update with capability information """ - new_packages = [] - for purl in diff.new_packages: - if purl.id in diff.new_capabilities: - new_purl = Purl( - **{**purl.__dict__, - "capabilities": diff.new_capabilities[purl.id]} - ) - new_packages.append(new_purl) - else: - new_packages.append(purl) - - diff.new_packages = new_packages + for attribute in ("new_packages", "updated_packages"): + packages = [] + for purl in getattr(diff, attribute): + if purl.id in diff.new_capabilities: + purl = Purl( + **{**purl.__dict__, + "capabilities": diff.new_capabilities[purl.id]} + ) + packages.append(purl) + setattr(diff, attribute, packages) def add_package_alerts_to_collection(self, package: Package, alerts_collection: dict, packages: dict) -> dict: """ diff --git a/socketsecurity/core/alert_selection.py b/socketsecurity/core/alert_selection.py index ae5b4772..132be294 100644 --- a/socketsecurity/core/alert_selection.py +++ b/socketsecurity/core/alert_selection.py @@ -31,7 +31,9 @@ def clone_diff_with_selected_alerts(diff: Diff, selected_alerts: List[Issue]) -> removed_alerts=[], diff_url=getattr(diff, "diff_url", ""), new_packages=getattr(diff, "new_packages", []), + updated_packages=getattr(diff, "updated_packages", []), removed_packages=getattr(diff, "removed_packages", []), + replaced_packages=getattr(diff, "replaced_packages", []), packages=getattr(diff, "packages", {}), ) selected_diff.id = getattr(diff, "id", "") diff --git a/socketsecurity/core/classes.py b/socketsecurity/core/classes.py index 2a19c2bb..fd8fabcd 100644 --- a/socketsecurity/core/classes.py +++ b/socketsecurity/core/classes.py @@ -559,7 +559,9 @@ class Diff: """ new_packages: list[Purl] + updated_packages: list[Purl] removed_packages: list[Purl] + replaced_packages: list[Purl] packages: dict[str, Package] new_capabilities: Dict[str, List[str]] new_alerts: list[Issue] @@ -570,6 +572,8 @@ class Diff: report_url: str diff_url: str new_scan_id: str + is_full_scan: bool + alerts_fetched: bool def __init__(self, **kwargs): if kwargs: @@ -577,8 +581,12 @@ def __init__(self, **kwargs): setattr(self, key, value) if not hasattr(self, "new_packages"): self.new_packages = [] + if not hasattr(self, "updated_packages"): + self.updated_packages = [] if not hasattr(self, "removed_packages"): self.removed_packages = [] + if not hasattr(self, "replaced_packages"): + self.replaced_packages = [] if not hasattr(self, "new_alerts"): self.new_alerts = [] if not hasattr(self, "unchanged_alerts"): @@ -587,6 +595,10 @@ def __init__(self, **kwargs): self.removed_alerts = [] if not hasattr(self, "new_capabilities"): self.new_capabilities = {} + if not hasattr(self, "is_full_scan"): + self.is_full_scan = False + if not hasattr(self, "alerts_fetched"): + self.alerts_fetched = False def __str__(self): return json.dumps(self.__dict__) @@ -600,8 +612,10 @@ def to_dict(self) -> dict: """ return { "new_packages": [p.to_dict() for p in self.new_packages], + "updated_packages": [p.to_dict() for p in self.updated_packages], "new_capabilities": self.new_capabilities, "removed_packages": [p.to_dict() for p in self.removed_packages], + "replaced_packages": [p.to_dict() for p in self.replaced_packages], "new_alerts": [alert.__dict__ for alert in self.new_alerts], "unchanged_alerts": [alert.__dict__ for alert in self.unchanged_alerts] if hasattr(self, "unchanged_alerts") else [], "removed_alerts": [alert.__dict__ for alert in self.removed_alerts] if hasattr(self, "removed_alerts") else [], diff --git a/socketsecurity/core/cli_client.py b/socketsecurity/core/cli_client.py index 2e941e7a..405a7443 100644 --- a/socketsecurity/core/cli_client.py +++ b/socketsecurity/core/cli_client.py @@ -56,7 +56,12 @@ def request( except requests.exceptions.RequestException as e: logger.error(f"API request failed: {str(e)}") - raise APIFailure(f"Request failed: {str(e)}") + # Carry the status forward. Callers that need to react to a specific + # code -- the GitLab auth fallback to the other token scheme, and + # APIFailure.is_transient_error -- have no other way to recover it + # once the requests exception has been translated. + status_code = e.response.status_code if e.response is not None else None + raise APIFailure(f"Request failed: {str(e)}", status_code=status_code) from e def post_telemetry_events(self, org_slug: str, events: List[Dict]) -> None: """Post telemetry events one at a time to the v0 telemetry API. Fire-and-forget โ€” logs errors but never raises.""" diff --git a/socketsecurity/core/exceptions.py b/socketsecurity/core/exceptions.py index 03e69b87..b2112ee0 100644 --- a/socketsecurity/core/exceptions.py +++ b/socketsecurity/core/exceptions.py @@ -1,3 +1,5 @@ +from socketdev.exceptions import APIFailure as SdkAPIFailure + __all__ = [ "APIFailure", "APIKeyMissing", @@ -18,8 +20,14 @@ class APIKeyMissing(Exception): pass -class APIFailure(Exception): - """Raised when there is an error using the API""" +class APIFailure(SdkAPIFailure): + """Raised when there is an error using the API. + + Subclasses the SDK's exception of the same name so a handler written against + either one catches both. A separate Exception subclass would bypass an + ``except APIFailure`` importing the SDK's -- which every handler in + socketsecurity.core does -- and would not carry the SDK class's status code. + """ pass @@ -39,4 +47,4 @@ class APIResourceNotFound(Exception): class RequestTimeoutExceeded(Exception): """Raised when access is denied to the API""" - pass \ No newline at end of file + pass diff --git a/socketsecurity/core/git_remote.py b/socketsecurity/core/git_remote.py new file mode 100644 index 00000000..eb5b9c02 --- /dev/null +++ b/socketsecurity/core/git_remote.py @@ -0,0 +1,43 @@ +"""Parsing for git remote URLs. + +CI systems that are not tied to a single SCM expose the checkout URL rather than +an ``owner/repo`` slug (Buildkite's ``BUILDKITE_REPO``, for example). Both the +GitHub comment adapter and pull request context resolution need to recover the +slug from it, so the parsing lives here rather than in either caller. +""" +import re +from typing import Optional, Tuple +from urllib.parse import urlparse + +# git@host:owner/repo - the scp-like syntax urlparse cannot handle. The negative +# lookahead keeps scheme-prefixed URLs (https://, ssh://) out of this case. +_SCP_LIKE_REMOTE = re.compile(r"^(?:[^@/]+@)?([^:/]+):(?!//)(.+)$") + + +def parse_git_remote(value: Optional[str]) -> Tuple[Optional[str], Optional[str]]: + """Split a git remote URL into its host and its repository path. + + Returns ``(host, path)``, or ``(None, None)`` when the value is not a usable + remote. The path is returned whole rather than as ``owner``/``repo`` because + GitLab projects can be nested under subgroups; callers that only want the + last two segments can split it themselves. ``host`` is ``None`` for a bare + ``owner/repo`` path, which carries no host to report. + """ + if not value: + return None, None + url = value.strip().rstrip("/") + if url.endswith(".git"): + url = url[:-4] + + match = _SCP_LIKE_REMOTE.match(url) + if match: + return match.group(1), match.group(2).strip("/") + + parsed = urlparse(url) + if parsed.scheme in ("http", "https", "ssh", "git") and parsed.hostname: + return parsed.hostname, parsed.path.strip("/") + + # A bare owner/repo path, with no scheme and nothing to infer a host from. + if "/" in url: + return None, url.strip("/") + return None, None diff --git a/socketsecurity/core/messages.py b/socketsecurity/core/messages.py index 14a6f829..4017a9f8 100644 --- a/socketsecurity/core/messages.py +++ b/socketsecurity/core/messages.py @@ -4,6 +4,7 @@ import re import uuid from datetime import datetime, timezone +from html import escape from pathlib import Path from mdutils import MdUtils @@ -15,6 +16,13 @@ class Messages: + @staticmethod + def get_patched_version(alert: Issue) -> str: + """Return the first patched version exposed by an alert, if any.""" + props = getattr(alert, "props", {}) or {} + value = props.get("firstPatchedVersionIdentifier") + return str(value) if value not in (None, "") else "" + @staticmethod def map_severity_to_sarif(severity: str) -> str: """ @@ -843,6 +851,37 @@ def inline_html_text(value) -> str: return "" return " ".join(str(value).split()) + @staticmethod + def html_text(value) -> str: + """Flatten a value onto one line and escape it for an HTML text node. + + Manifest paths and sources come from the customer's repository, so any PR + author controls them: a directory named ``![x](https://host/p.png)`` or + carrying a raw tag would otherwise render as that markup inside a comment + posted by a trusted integration. Alert text comes from the API and is + escaped for the same reason, since neither is markup the CLI authored. + """ + return escape(Messages.inline_html_text(value)) + + @staticmethod + def html_attr(value) -> str: + """Escape a value for an HTML attribute, quotes included. + + Used for href and src, where an unescaped quote closes the attribute and + everything after it is read as more attributes. + """ + return escape(Messages.inline_html_text(value), quote=True) + + @staticmethod + def comment_marker_text(value) -> str: + """Neutralize an HTML comment terminator inside a marker value. + + The alert markers carry the package name so the comment can be rewritten + later, and the parser reads them back verbatim -- so this cannot escape the + value, only stop it ending the comment early. + """ + return str(value or "").replace("-->", "-->").replace(" @@ -963,39 +1002,48 @@ def security_comment_template(diff: Diff, config=None) -> str: severity_icon = Messages.get_severity_icon(alert.severity) action = "Block" if alert.error else "Warn" details_open = "" + patched_version = Messages.get_patched_version(alert) + patched_version_html = ( + "

Patched version: " + f"{Messages.html_text(patched_version)}

" + if patched_version else "" + ) # Generate proper manifest URL manifest_url = Messages.get_manifest_file_url(diff, alert.manifests, config) + pkg_label = Messages.html_text(f"{alert.pkg_name}@{alert.pkg_version}") + pkg_marker = Messages.comment_marker_text(f"{alert.pkg_name}@{alert.pkg_version}") # Generate a table row for each alert ignore_html = ( f"

Mark as acceptable risk: To ignore this alert only in this pull request, reply with:
" - f"@SocketSecurity ignore {alert.pkg_name}@{alert.pkg_version}
" + f"@SocketSecurity ignore {Messages.html_text(alert.pkg_type)}/{pkg_label}
" f"Or ignore all future alerts with:
" f"@SocketSecurity ignore-all

" ) if show_ignore else "" comment += f""" - + - + """ # Add license policy violation entries grouped by PURL @@ -1006,24 +1054,31 @@ def security_comment_template(diff: Diff, config=None) -> str: # Use orange diamond for license policy violations license_icon = "๐Ÿ”ถ" + license_label = Messages.html_text( + f"{first_alert.pkg_name}@{first_alert.pkg_version}" + ) + license_marker = Messages.comment_marker_text( + f"{first_alert.pkg_name}@{first_alert.pkg_version}" + ) + # Build license findings list license_findings = [] for alert in alerts: license_findings.append(alert.title) comment += f""" - + - + """ # Close table @@ -1246,6 +1301,22 @@ def create_remove_line(diff: Diff, md: MdUtils) -> MdUtils: md.new_line(removed_line) return md + # Change types the shared badge host publishes an image for. Removed and + # replaced have no artwork, so they fall back to a bold text label rather than + # rendering a broken image; added and updated render the available badges. + DIFF_BADGES = { + "Added": "diff-added.svg", + "Updated": "diff-updated.svg", + } + + @staticmethod + def get_diff_badge(change: str, package_url: str) -> str: + """Return the Dependency Overview cell marking how a package changed.""" + badge = Messages.DIFF_BADGES.get(change) + if not badge: + return f"**{change}**" + return f"[![{change}](https://github-app-statics.socket.dev/{badge})]({package_url})" + @staticmethod def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: """ @@ -1267,51 +1338,58 @@ def create_added_table(diff: Diff, md: MdUtils) -> MdUtils: num_of_overview_columns = len(overview_table) count = 0 - for added in diff.new_packages: - added: Purl # Ensure `added` has scores and relevant attributes. - - package_url = f"[{added.purl}]({added.url})" - diff_badge = f"[![+](https://github-app-statics.socket.dev/diff-added.svg)]({added.url})" - - # Scores dynamically converted to badge URLs and linked - def score_to_badge(score): - score_percent = int(score * 100) # Convert to integer percentage - return f"[![{score_percent}](https://github-app-statics.socket.dev/score-{score_percent}.svg)]({added.url})" - - def get_score_for_badge(score_name: str) -> float: - scores = getattr(added, "scores", None) - if isinstance(scores, dict): - raw_score = scores.get(score_name) - else: - raw_score = getattr(scores, score_name, None) if scores is not None else None - - if raw_score is None: - return 1.0 - - score = float(raw_score) - if score > 1: - score = score / 100 - return max(0.0, min(score, 1.0)) - - # Generate badges for each score type - supply_chain_risk_badge = score_to_badge(get_score_for_badge("supplyChain")) - vulnerability_badge = score_to_badge(get_score_for_badge("vulnerability")) - quality_badge = score_to_badge(get_score_for_badge("quality")) - maintenance_badge = score_to_badge(get_score_for_badge("maintenance")) - license_badge = score_to_badge(get_score_for_badge("license")) - - # Add the row for this package - row = [ - diff_badge, - package_url, - supply_chain_risk_badge, - vulnerability_badge, - quality_badge, - maintenance_badge, - license_badge - ] - overview_table.extend(row) - count += 1 # Count total packages + changes = ( + ("Added", diff.new_packages), + ("Updated", diff.updated_packages), + ("Removed", diff.removed_packages), + ("Replaced", diff.replaced_packages), + ) + for change, packages in changes: + for package in packages: + package: Purl + + package_url = f"[{package.purl}]({package.url})" + diff_badge = Messages.get_diff_badge(change, package.url) + + # Scores dynamically converted to badge URLs and linked + def score_to_badge(score): + score_percent = int(score * 100) # Convert to integer percentage + return f"[![{score_percent}](https://github-app-statics.socket.dev/score-{score_percent}.svg)]({package.url})" + + def get_score_for_badge(score_name: str) -> float: + scores = getattr(package, "scores", None) + if isinstance(scores, dict): + raw_score = scores.get(score_name) + else: + raw_score = getattr(scores, score_name, None) if scores is not None else None + + if raw_score is None: + return 1.0 + + score = float(raw_score) + if score > 1: + score = score / 100 + return max(0.0, min(score, 1.0)) + + # Generate badges for each score type + supply_chain_risk_badge = score_to_badge(get_score_for_badge("supplyChain")) + vulnerability_badge = score_to_badge(get_score_for_badge("vulnerability")) + quality_badge = score_to_badge(get_score_for_badge("quality")) + maintenance_badge = score_to_badge(get_score_for_badge("maintenance")) + license_badge = score_to_badge(get_score_for_badge("license")) + + # Add the row for this package + row = [ + diff_badge, + package_url, + supply_chain_risk_badge, + vulnerability_badge, + quality_badge, + maintenance_badge, + license_badge + ] + overview_table.extend(row) + count += 1 # Calculate total rows for table num_of_overview_rows = count + 1 # Include header row @@ -1346,6 +1424,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: [ "Alert", "Package", + "Patched Version", "url", "Introduced by", "Manifest File", @@ -1366,6 +1445,7 @@ def create_console_security_alert_table(diff: Diff) -> PrettyTable: row = [ alert.title, alert.purl, + Messages.get_patched_version(alert), alert.url, source_str, manifest_str, @@ -1381,8 +1461,11 @@ def create_sources(alert: Issue, style="md") -> tuple[str, str]: for source, manifest in alert.introduced_by: if style == "md": - add_str = f"
  • {manifest}
  • " - source_str = f"
  • {source}
  • " + # These land in rendered Markdown, where an unescaped path is read + # as markup. plain and raw are consumed by Slack, Jira and the + # console, which do not render HTML, so they stay verbatim. + add_str = f"
  • {Messages.html_text(manifest)}
  • " + source_str = f"
  • {Messages.html_text(source)}
  • " elif style == "plain": add_str = f"โ€ข {manifest}" source_str = f"โ€ข {source}" diff --git a/socketsecurity/core/pull_request.py b/socketsecurity/core/pull_request.py new file mode 100644 index 00000000..2dcf77f1 --- /dev/null +++ b/socketsecurity/core/pull_request.py @@ -0,0 +1,144 @@ +import re +from dataclasses import dataclass +from typing import Mapping, Optional +from urllib.parse import urlparse + +from socketsecurity.core.git_remote import parse_git_remote + + +@dataclass(frozen=True) +class PullRequestContext: + number: int = 0 + url: Optional[str] = None + + +def parse_pull_request_number(value) -> int: + """Coerce a configured or CI-supplied pull request number to a positive int. + + Anything that is not a positive integer means "no pull request", including the + literal ``false`` that Buildkite puts in ``BUILDKITE_PULL_REQUEST`` on non-PR + builds. Callers that hand the value on to a comment adapter should store this + result rather than the raw string, which is truthy. + """ + try: + parsed = int(value) + except (TypeError, ValueError): + return 0 + return parsed if parsed > 0 else 0 + + +def _http_url(value: Optional[str]) -> Optional[str]: + """Return ``value`` if it is an http(s) URL with a host, else ``None``. + + Every URL fragment read out of the CI environment goes through here before it + is composed into a link, because the result is sent to the API as a diff scan's + ``external_href``. Standard runners set these variables themselves, so this is + defense in depth rather than a live hole. + """ + if not value: + return None + url = value.strip().rstrip("/") + parsed = urlparse(url) + return url if parsed.scheme in ("http", "https") and parsed.netloc else None + + +def _repository_url(value: Optional[str]) -> Optional[str]: + if not value: + return None + url = value.strip().rstrip("/") + if url.endswith(".git"): + url = url[:-4] + return _http_url(url) + + +def _github_number(env: Mapping[str, str]) -> int: + number = parse_pull_request_number(env.get("PR_NUMBER")) + if number: + return number + match = re.match(r"^refs/pull/(\d+)/", env.get("GITHUB_REF", "")) + return parse_pull_request_number(match.group(1)) if match else 0 + + +def _github_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]: + remote_host, remote_path = parse_git_remote(env.get("BUILDKITE_REPO")) + # config.repo is only ever a bare repository name, so it cannot produce a + # slug on its own; it is kept last for callers that pass a full owner/repo. + repository = env.get("GITHUB_REPOSITORY") or remote_path or repo + if not repository or "/" not in repository: + return None + server = ( + _http_url(env.get("GITHUB_SERVER_URL")) + or (_http_url(f"https://{remote_host}") if remote_host else None) + or "https://github.com" + ) + return f"{server}/{repository.strip('/')}/pull/{number}" + + +def _gitlab_url(number: int, repo: Optional[str], env: Mapping[str, str]) -> Optional[str]: + project_url = _repository_url(env.get("CI_PROJECT_URL")) + if not project_url: + remote_host, remote_path = parse_git_remote(env.get("BUILDKITE_REPO")) + project_path = env.get("CI_PROJECT_PATH") or remote_path or repo + server = ( + _http_url(env.get("CI_SERVER_URL")) + or (_http_url(f"https://{remote_host}") if remote_host else None) + ) + if server and project_path and "/" in project_path: + project_url = f"{server}/{project_path.strip('/')}" + return f"{project_url}/-/merge_requests/{number}" if project_url else None + + +def _azure_url(number: int, env: Mapping[str, str], github_pr: bool) -> Optional[str]: + repository_url = _repository_url( + env.get("BUILD_REPOSITORY_URI") or + env.get("SYSTEM_PULLREQUEST_SOURCEREPOSITORYURI") + ) + if not repository_url: + return None + github_pr = github_pr or "github" in urlparse(repository_url).netloc.lower() + path = "pull" if github_pr else "pullrequest" + return f"{repository_url}/{path}/{number}" + + +def resolve_pull_request_context( + integration_type: str, + configured_number, + repo: Optional[str], + *, + configured_explicit: bool = False, + env: Optional[Mapping[str, str]] = None, +) -> PullRequestContext: + """Resolve PR metadata without making provider API calls. + + Explicit CLI/config values win, including an explicit zero used to disable + association. Otherwise the provider's standard CI environment is used. + """ + environment = env or {} + provider = str(integration_type or "api").lower() + number = parse_pull_request_number(configured_number) + + if not configured_explicit and not number: + if provider == "github": + number = _github_number(environment) + elif provider == "gitlab": + number = parse_pull_request_number(environment.get("CI_MERGE_REQUEST_IID")) + elif provider == "azure": + number = ( + parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) or + parse_pull_request_number(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTID")) + ) + + if not number: + return PullRequestContext() + + if provider == "github": + url = _github_url(number, repo, environment) + elif provider == "gitlab": + url = _gitlab_url(number, repo, environment) + elif provider == "azure": + github_pr = bool(environment.get("SYSTEM_PULLREQUEST_PULLREQUESTNUMBER")) + url = _azure_url(number, environment, github_pr) + else: + url = None + + return PullRequestContext(number=number, url=url) diff --git a/socketsecurity/core/scm/github.py b/socketsecurity/core/scm/github.py index 7504a46c..dcfa5607 100644 --- a/socketsecurity/core/scm/github.py +++ b/socketsecurity/core/scm/github.py @@ -1,7 +1,6 @@ import json import os import sys -import urllib.parse from dataclasses import dataclass from git import Optional @@ -9,6 +8,8 @@ from socketsecurity import USER_AGENT from socketsecurity.core import log from socketsecurity.core.classes import Comment +from socketsecurity.core.exceptions import APIFailure +from socketsecurity.core.git_remote import parse_git_remote from socketsecurity.core.scm_comments import Comments from socketsecurity.socketcli import CliClient @@ -38,24 +39,12 @@ class GithubConfig: @staticmethod def _repository_from_buildkite() -> tuple[str, str]: """Return ``(owner, repository)`` from Buildkite's Git repository URL.""" - repository_url = ( - # Comments and statuses belong to the pipeline/base repository, - # not a contributor's fork from BUILDKITE_PULL_REQUEST_REPO. - os.getenv("BUILDKITE_REPO") - or os.getenv("BUILDKITE_PULL_REQUEST_REPO") - or "" - ).strip() - if not repository_url: - return "", "" - - if "://" in repository_url: - repository_path = urllib.parse.urlparse(repository_url).path - elif ":" in repository_url: - # SCP-style SSH URL: git@github.com:owner/repository.git - repository_path = repository_url.split(":", 1)[1] - else: - repository_path = repository_url - parts = repository_path.strip("/").removesuffix(".git").split("/") + # Comments and statuses belong to the pipeline/base repository, not a + # contributor's fork from BUILDKITE_PULL_REQUEST_REPO. + _, repository_path = parse_git_remote( + os.getenv("BUILDKITE_REPO") or os.getenv("BUILDKITE_PULL_REQUEST_REPO") + ) + parts = repository_path.split("/") if repository_path else [] if len(parts) < 2: return "", "" return parts[-2], parts[-1] @@ -166,9 +155,21 @@ def from_env(cls, pr_number: Optional[str] = None) -> 'GithubConfig': class Github: - def __init__(self, client: CliClient, config: Optional[GithubConfig] = None): + WRITE_PERMISSIONS = frozenset({"write", "maintain", "admin"}) + + def __init__( + self, + client: CliClient, + config: Optional[GithubConfig] = None, + ignore_authorization: str = "enforce", + ): self.config = config or GithubConfig.from_env() self.client = client + self.ignore_authorization = ignore_authorization + # Permission is stable for the duration of one CLI run. Cache both + # positive and negative answers so several ignore comments by the same + # author do not each make an API request. + self._ignore_permission_cache: dict[str, Optional[bool]] = {} if not self.config.token: log.error("Unable to get Github API Token") @@ -236,7 +237,76 @@ def get_comments_for_pr(self) -> dict: else: log.error(raw_comments) - return Comments.check_for_socket_comments(comments) + gate = None if self.ignore_authorization == "off" else self.is_ignore_authorized + return Comments.check_for_socket_comments(comments, gate) + + def is_ignore_authorized(self, comment: Comment) -> bool: + """Whether a commenter may suppress alerts with @SocketSecurity ignore. + + ``author_association`` describes a social relationship to the repository, + not the author's role: an organization member or outside collaborator can + still have read-only access. Ask GitHub for the effective repository + permission instead, and cache the answer for subsequent comments. + """ + author = Comments.comment_author_name(comment) + if author == "an unknown user": + permission = None + elif author in self._ignore_permission_cache: + permission = self._ignore_permission_cache[author] + else: + path = ( + f"repos/{self.config.owner}/{self.config.repository}/" + f"collaborators/{author}/permission" + ) + try: + response = self.client.request( + path=path, + headers=self.config.headers, + base_url=self.config.api_url, + ) + result = response.json() + if not isinstance(result, dict) or not isinstance( + result.get("permission"), str + ): + log.warning("Unexpected GitHub repository permission response") + permission = None + else: + permission = ( + result["permission"].casefold() in self.WRITE_PERMISSIONS + ) + except APIFailure as error: + if getattr(error, "status_code", None) == 404: + # The repository was readable when its comments were listed, + # so a missing collaborator permission is a definitive denial. + permission = False + else: + log.warning( + "Could not read GitHub repository permission for " + f"{author}: {error}" + ) + permission = None + except Exception as error: + log.warning( + f"Could not read GitHub repository permission for {author}: {error}" + ) + permission = None + self._ignore_permission_cache[author] = permission + + if permission is not None: + return permission + if self.ignore_authorization == "strict": + log.warning( + f"Rejecting @SocketSecurity ignore from {author}: GitHub repository " + "permission could not be read and --ignore-authorization is strict." + ) + return False + log.warning( + f"Honoring @SocketSecurity ignore from {author} without verifying write " + "access: GitHub repository permission could not be read. Use a token " + "with repository metadata access, or --ignore-authorization strict to " + "reject instead." + ) + return True def add_socket_comments( self, diff --git a/socketsecurity/core/scm/gitlab.py b/socketsecurity/core/scm/gitlab.py index 2c3947de..d1fd1119 100644 --- a/socketsecurity/core/scm/gitlab.py +++ b/socketsecurity/core/scm/gitlab.py @@ -5,6 +5,7 @@ from typing import Optional import requests +from socketdev.exceptions import APIFailure from socketsecurity import USER_AGENT from socketsecurity.core import log @@ -126,37 +127,54 @@ def _get_auth_headers(token: str) -> dict: } class Gitlab: - def __init__(self, client: CliClient, config: Optional[GitlabConfig] = None): + # GitLab access levels: 30 Developer, 40 Maintainer, 50 Owner. Reporter (20) + # and Guest (10) cannot push, so they cannot suppress an alert either. + MIN_IGNORE_ACCESS_LEVEL = 30 + # Bounded so a project with a very large membership cannot stall a scan. Past + # the cap the answer is "undetermined", handled the same as a failed lookup. + MEMBER_PAGE_SIZE = 100 + MEMBER_PAGE_LIMIT = 10 + + def __init__( + self, + client: CliClient, + config: Optional[GitlabConfig] = None, + ignore_authorization: str = "enforce", + ): self.config = config or GitlabConfig.from_env() self.client = client + self.ignore_authorization = ignore_authorization + # None until the first ignore comment forces a lookup; stays None when the + # members API cannot be read, which is the "undetermined" state. + self._member_access: Optional[dict] = None + self._member_lookup_attempted = False def _request_with_fallback(self, **kwargs): - """ - Make a request with automatic fallback between Bearer and PRIVATE-TOKEN authentication. - This provides robustness when the initial token type detection is incorrect. + """Request with one retry under the other GitLab auth scheme on a 401. + + _get_auth_headers guesses between Bearer and PRIVATE-TOKEN from the shape of + the token, and the guess can be wrong for tokens that do not match a known + pattern. Rather than fail the run, try the other scheme once. + + Catches APIFailure, not requests.exceptions.HTTPError: CliClient translates + every requests error into APIFailure, which does not inherit from HTTPError, + so catching the latter here never fired and the fallback never ran. """ try: - # Try the initial request with the configured headers return self.client.request(**kwargs) - except requests.exceptions.HTTPError as e: - # Check if this is an authentication error (401) - if e.response and e.response.status_code == 401: - log.debug("Authentication failed with initial headers, trying fallback method") - - # Determine the fallback headers - original_headers = kwargs.get('headers', self.config.headers) - fallback_headers = self._get_fallback_headers(original_headers) - - if fallback_headers and fallback_headers != original_headers: - log.debug("Retrying request with fallback authentication method") - kwargs['headers'] = fallback_headers - return self.client.request(**kwargs) - - # Re-raise the original exception if it's not an auth error or fallback failed - raise - except Exception: - # Handle other types of exceptions that don't have response attribute - raise + except APIFailure as error: + if error.status_code != 401: + raise + + log.debug("Authentication failed with initial headers, trying fallback method") + original_headers = kwargs.get('headers', self.config.headers) + fallback_headers = self._get_fallback_headers(original_headers) + if not fallback_headers or fallback_headers == original_headers: + raise + + log.debug("Retrying request with fallback authentication method") + kwargs['headers'] = fallback_headers + return self.client.request(**kwargs) def _get_fallback_headers(self, original_headers: dict) -> dict: """ @@ -256,7 +274,88 @@ def get_comments_for_pr(self) -> dict: comment.body_list = comment.body.split("\n") else: log.error(raw_comments) - return Comments.check_for_socket_comments(comments) + gate = None if self.ignore_authorization == "off" else self.is_ignore_authorized + return Comments.check_for_socket_comments(comments, gate) + + def _load_member_access(self) -> Optional[dict]: + """Map project member user id -> access level, or None if unreadable. + + ``members/all`` is used rather than a per-user lookup because it answers + non-membership with a 200 and an absent id. CliClient collapses every HTTP + error into APIFailure without a status code, so a per-user 404 -- exactly + the outsider case this guards against -- would be indistinguishable from a + token that cannot read the endpoint, and would have to fail open. + """ + if self._member_lookup_attempted: + return self._member_access + self._member_lookup_attempted = True + if not self.config.mr_project_id: + return None + + access: dict = {} + for page in range(1, Gitlab.MEMBER_PAGE_LIMIT + 1): + path = ( + f"projects/{self.config.mr_project_id}/members/all" + f"?per_page={Gitlab.MEMBER_PAGE_SIZE}&page={page}" + ) + try: + response = self._request_with_fallback( + path=path, + headers=self.config.headers, + base_url=self.config.api_url + ) + members = response.json() + except Exception as error: + log.warning(f"Could not read GitLab project members: {error}") + return None + if not isinstance(members, list): + log.warning("Unexpected GitLab project members response") + return None + for member in members: + if isinstance(member, dict) and member.get("id") is not None: + access[member["id"]] = member.get("access_level") or 0 + if len(members) < Gitlab.MEMBER_PAGE_SIZE: + self._member_access = access + return access + + log.warning( + f"GitLab project has more than {Gitlab.MEMBER_PAGE_SIZE * Gitlab.MEMBER_PAGE_LIMIT} " + "members; cannot confirm ignore-command authorization" + ) + return None + + def is_ignore_authorized(self, comment: Comment) -> bool: + """Whether a commenter may suppress alerts with @SocketSecurity ignore. + + GitLab notes carry no permission field, so this costs one members lookup + per run (cached, and only when an ignore command is actually present). + + When membership can be read the answer is definitive. When it cannot -- a + CI_JOB_TOKEN generally cannot read the members API -- the command is + honored and a warning is logged, preserving compatibility for pipelines + that rely on ignore commands. Set a token with API read access to get + enforcement. + """ + access = self._load_member_access() + if access is None: + author = Comments.comment_author_name(comment) + if self.ignore_authorization == "strict": + log.warning( + f"Rejecting @SocketSecurity ignore from {author}: GitLab project " + "membership could not be read and --ignore-authorization is strict." + ) + return False + log.warning( + f"Honoring @SocketSecurity ignore from {author} without verifying " + "write access: GitLab project membership could not be read. Use a " + "token with API read access, or --ignore-authorization strict to " + "reject instead." + ) + return True + + author = getattr(comment, "author", None) or {} + user_id = author.get("id") + return access.get(user_id, 0) >= Gitlab.MIN_IGNORE_ACCESS_LEVEL def add_socket_comments( self, diff --git a/socketsecurity/core/scm_comments.py b/socketsecurity/core/scm_comments.py index 7c479b72..3ef0e3a6 100644 --- a/socketsecurity/core/scm_comments.py +++ b/socketsecurity/core/scm_comments.py @@ -1,5 +1,6 @@ import json import re +from typing import Callable, Optional from requests import Response @@ -11,6 +12,12 @@ class Comments: VIEW_REPORT_PATTERN = re.compile(r"\[View full report\]\(([^)\s]+)\)") + @staticmethod + def comment_author_name(comment: Comment) -> str: + """Best-effort display name for a comment author, across providers.""" + user = getattr(comment, "user", None) or getattr(comment, "author", None) or {} + return user.get("login") or user.get("username") or "an unknown user" + @staticmethod def process_response(response: Response) -> dict: output = {} @@ -37,10 +44,10 @@ def remove_alerts(comments: dict, new_alerts: list) -> list: if ignore_all: break else: - full_name = f"{alert.pkg_type}/{alert.pkg_name}" - purl = (full_name, alert.pkg_version) - purl_star = (full_name, "*") - if purl in ignore_commands or purl_star in ignore_commands: + if any( + Comments.is_ignore(alert.pkg_name, alert.pkg_version, name, version, alert.pkg_type) + for name, version in ignore_commands + ): log.info(f"Alerts for {alert.pkg_name}@{alert.pkg_version} ignored") else: log.info(f"Adding alert {alert.type} for {alert.pkg_name}@{alert.pkg_version}") @@ -66,8 +73,10 @@ def get_ignore_options(comments: dict) -> [bool, list]: ignore_all = True else: command = command.lstrip("ignore").strip() - name, version = command.split("@") - data = (name, version) + name, separator, version = command.rpartition("@") + if not separator or not name or not version: + raise ValueError("Expected package@version") + data = (name.strip(), version.strip()) ignore_commands.append(data) except Exception as error: log.error(f"Unable to process ignore command for {comment}") @@ -75,11 +84,30 @@ def get_ignore_options(comments: dict) -> [bool, list]: return ignore_all, ignore_commands @staticmethod - def is_ignore(pkg_name: str, pkg_version: str, name: str, version: str) -> bool: - result = False - if pkg_name == name and (pkg_version == version or version == "*"): - result = True - return result + def is_ignore( + pkg_name: str, pkg_version: str, name: str, version: str, + pkg_type: str = "" + ) -> bool: + """Match an alert's package against one parsed ignore command. + + Generated commands are ecosystem-qualified (``npm/lodash@4.17.21``) but + replies typed by hand, and commands written by older CLI versions, use the + bare package name, so both have to match. + + Callers that parse the package out of a ``start-socket-alert`` marker have no + pkg_type to compare against and instead strip the ecosystem off the command. + An npm scope looks the same as an ecosystem prefix there, so only strip when + the leading segment cannot be one: without the guard, + ``ignore @types/node@*`` would also silently ignore alerts for a package + literally named ``node``. + """ + package_names = {pkg_name} + if pkg_type: + package_names.add(f"{pkg_type}/{pkg_name}") + target_names = {name} + if not pkg_type and "/" in name and not name.startswith("@"): + target_names.add(name.split("/", 1)[1]) + return bool(package_names & target_names) and (pkg_version == version or version == "*") @staticmethod def is_heading_line(line) -> bool: @@ -112,6 +140,33 @@ def process_security_comment(comment: Comment, comments) -> str: return new_body + @staticmethod + def parse_alert_table_row(line: str) -> Optional[tuple[str, str, str]]: + """Pull ``(ecosystem, package, version)`` out of a legacy alert table row. + + Returns None for any row that does not have the expected shape rather than + raising. The row comes back from the provider's API, so its contents are + outside this process's control. Malformed cells must not interrupt status + reporting. A row that cannot be read is a row whose alert stays reported. + """ + cells = line.strip().lstrip("|").rstrip("|").split("|") + if len(cells) != 5: + return None + package = cells[1] + if "](" not in package: + return None + details = package.split("](", 1)[0].lstrip("[") + if "/" not in details: + return None + ecosystem, remainder = details.split("/", 1) + if "@" not in remainder: + return None + # Split from the right: a scoped name carries its own "@". + pkg_name, pkg_version = remainder.rsplit("@", 1) + if not pkg_name or not pkg_version: + return None + return ecosystem, pkg_name, pkg_version + @staticmethod def process_original_security_comment( comment: Comment, @@ -127,19 +182,21 @@ def process_original_security_comment( start = True lines.append(line) elif start and "end-socket-alerts-table" not in line and not Comments.is_heading_line(line) and line != '': - title, package, introduced_by, manifest, ci = line.lstrip("|").rstrip("|").split("|") - details, _ = package.split("](") - ecosystem, details = details.split("/", 1) - ecosystem = ecosystem.lstrip("[") - pkg_name, pkg_version = details.split("@") - pkg_name = f"{ecosystem}/{pkg_name}" + parsed = Comments.parse_alert_table_row(line) # ignore_all has to be checked outside the loop: an ignore-all # comment produces no ignore_commands, so a loop-internal check # never runs and every row was kept. - ignore = ignore_all or any( - Comments.is_ignore(pkg_name, pkg_version, name, version) - for name, version in ignore_commands - ) + if parsed is None: + # An unparseable row cannot be evaluated against the ignore + # commands, so keep it: leaving an alert reported is the safe + # direction, and the comment body is not ours to discard. + ignore = ignore_all + else: + ecosystem, pkg_name, pkg_version = parsed + ignore = ignore_all or any( + Comments.is_ignore(pkg_name, pkg_version, name, version, ecosystem) + for name, version in ignore_commands + ) if not ignore: kept_alert = True lines.append(line) @@ -187,7 +244,7 @@ def process_updated_security_comment( # Extract package name and version from the comment try: start_marker = stripped[len("" in body assert "" in body + def test_copy_is_provider_neutral(self): + body = Messages.security_comment_template( + _make_diff([_make_alert()]), _FakeConfig(scm="gitlab") + ) + assert "Socket for GitHub" not in body + assert "Learn more about [Socket]" in body + class TestSecurityCommentTemplateWithNoAlerts: def test_no_alerts_omits_the_empty_table(self): @@ -232,6 +241,23 @@ def test_ignoring_every_alert_individually_collapses_too(self): assert "No dependency alerts to report" in new_body + def test_qualified_scoped_package_ignore_matches_comment_marker(self): + security = _security_comment_with([ + _make_alert( + pkg_name="@socketsecurity/example", + purl="pkg:npm/@socketsecurity/example@4.17.21", + ) + ]) + comments = { + "security": security, + "ignore": [_make_comment( + "SocketSecurity ignore npm/@socketsecurity/example@4.17.21", + comment_id=2, + )], + } + + assert "No dependency alerts to report" in Comments.process_security_comment(security, comments) + def test_no_ignore_commands_leaves_alerts_in_place(self): security = self._two_alert_comment() comments = {"security": security, "ignore": []} @@ -265,6 +291,17 @@ def test_collapsed_body_is_stable_when_reprocessed(self): [View full report](https://socket.dev/report/legacy?action=error%2Cwarn) """ +SCOPED_LEGACY_COMMENT = """ + + +|Alert|Package|Introduced by|Manifest File|CI| +|:---|:---|:---|:---|:---| +|Known Malware|[npm/@socketsecurity/example@1.0.0](https://socket.dev/z)|example|package.json|:no_entry_sign:| + + +[View full report](https://socket.dev/report/legacy?action=error%2Cwarn) +""" + class TestProcessOriginalSecurityComment: def test_partial_ignore_keeps_remaining_row(self): @@ -292,6 +329,27 @@ def test_ignore_all_collapses_to_the_no_alerts_body(self): assert "No dependency alerts to report" in new_body assert "[View full report](https://socket.dev/report/legacy)" in new_body + def test_scoped_package_row_does_not_raise(self): + """A scoped name carries its own "@", so the split must come from the right.""" + security = _make_comment(SCOPED_LEGACY_COMMENT) + comments = {"security": security, "ignore": []} + + new_body = Comments.process_security_comment(security, comments) + + assert "npm/@socketsecurity/example@1.0.0" in new_body + + def test_scoped_package_row_is_ignorable_both_ways(self): + for command in ( + "SocketSecurity ignore npm/@socketsecurity/example@1.0.0", + "SocketSecurity ignore @socketsecurity/example@1.0.0", + ): + security = _make_comment(SCOPED_LEGACY_COMMENT) + comments = {"security": security, "ignore": [_make_comment(command, comment_id=2)]} + + new_body = Comments.process_security_comment(security, comments) + + assert "No dependency alerts to report" in new_body, command + class TestExtractReportUrl: def test_strips_the_action_filter(self): @@ -302,3 +360,146 @@ def test_strips_the_action_filter(self): def test_returns_empty_when_absent(self): assert Comments.extract_report_url("no link here") == "" + + +# --- Escaping repo-derived values --------------------------------------------- +# +# Manifest paths and sources are file paths inside the customer's repository, so +# anyone who can open a pull request controls them: a directory named +# `![x](https://host/p.png)` holding a manifest puts that markup into a comment +# posted by a trusted integration. GitHub and GitLab sanitize comment HTML, so the +# exposure is external resource loading, phishing links and content spoofing +# rather than script execution. + + +@dataclass +class _RepoConfig(_FakeConfig): + """A config that reaches the branch which embeds the path verbatim. + + Without repo/branch, get_manifest_file_url returns "" or a percent-encoded + Socket link, and the path never lands in the comment -- so a test using the + bare config asserts nothing. + """ + repo: str = "acme/widgets" + branch: str = "main" + + +HOSTILE_PATHS = { + "image": "![x](https://evil.example/p.png)/package.json", + "link": "[click me](https://evil.example)/package.json", + "raw_tag": "/package.json", + "backtick": "`code`/package.json", + "pipe": "a|b/package.json", + "quote": 'a" onmouseover="x/package.json', + "comment_close": "x-->y/package.json", +} + + +def _rendered_with_path(path: str) -> str: + return Messages.security_comment_template( + _make_diff([_make_alert(manifests=path)]), _RepoConfig() + ) + + +def test_the_hostile_path_actually_reaches_the_comment(): + """Guards the fixture itself: if the path stops being rendered, the escaping + tests below would pass while asserting nothing.""" + body = _rendered_with_path("sentinel-path/package.json") + + assert "sentinel-path" in body + + +@pytest.mark.parametrize("name,path", sorted(HOSTILE_PATHS.items())) +def test_hostile_manifest_path_cannot_introduce_markup(name, path): + """In the rendered comment the path only ever lands inside an href, where + Markdown is inert. The property that matters there is that the value cannot + open a tag or close the attribute -- see create_sources for the context where + Markdown itself is live.""" + body = _rendered_with_path(path) + + rendered = [ln for ln in body.split("\n") if "Manifest File" in ln][0] + value = rendered.split('href="', 1)[1].split('"', 1)[0] + + for char in ("<", ">", '"'): + assert char not in value, f"{char!r} survived into the href: {value!r}" + assert_html_block_intact(body) + + +def test_quote_in_a_path_cannot_escape_the_href(): + body = _rendered_with_path('a" onmouseover="x/package.json') + + assert """ in body + assert 'href="https://github.com/acme/widgets/blob/main/a" ' not in body + + +def test_hostile_package_name_cannot_close_the_alert_marker(): + body = Messages.security_comment_template( + _make_diff([_make_alert(pkg_name="evil-->x")]), _FakeConfig() + ) + + # Exactly the terminator the CLI wrote, and no stray one inside the value. + for line in body.split("\n"): + if "socket-alert-" in line: + assert line.count("-->") == 1, line + + +def test_alert_text_from_the_api_is_escaped(): + body = Messages.security_comment_template( + _make_diff([_make_alert(description="")]), _FakeConfig() + ) + + assert "
    {action} - {alert.severity} + {Messages.html_attr(alert.severity)}
    - {alert.pkg_name}@{alert.pkg_version} - {Messages.inline_html_text(alert.title)} -

    Note: {Messages.inline_html_text(alert.description)}

    -

    Source: Manifest File

    + {pkg_label} - {Messages.html_text(alert.title)} +

    Note: {Messages.html_text(alert.description)}

    + {patched_version_html} +

    Source: Manifest File

    โ„น๏ธ Read more on: - This package | - This alert | + This package | + This alert | What is known malware?

    -

    Suggestion: {Messages.inline_html_text(alert.suggestion)}

    +

    Suggestion: {Messages.html_text(alert.suggestion)}

    {ignore_html}
    {action} {license_icon}
    - {first_alert.pkg_name}@{first_alert.pkg_version} has a License Policy Violation. + {license_label} has a License Policy Violation.

    License findings:

      """ for finding in license_findings: - comment += f"
    • {Messages.inline_html_text(finding)}
    • \n" + comment += f"
    • {Messages.html_text(finding)}
    • \n" # Generate proper manifest URL for license violations @@ -1031,13 +1086,13 @@ def security_comment_template(diff: Diff, config=None) -> str: license_ignore_html = ( f"

      Mark the package as acceptable risk: To ignore this alert only in this pull request, reply with the comment " - f"@SocketSecurity ignore {first_alert.pkg_name}@{first_alert.pkg_version}. " + f"@SocketSecurity ignore {Messages.html_text(first_alert.pkg_type)}/{license_label}. " f"You can also ignore all packages with @SocketSecurity ignore-all. " f"To ignore an alert for all future pull requests, use Socket's Dashboard to change the triage state of this alert.

      " ) if show_ignore else "" comment += f"""
    -

    From: Manifest File

    -

    โ„น๏ธ Read more on: This package | What is a license policy violation?

    +

    From: Manifest File

    +

    โ„น๏ธ Read more on: This package | What is a license policy violation?

    Next steps: Take a moment to review the security alert above. Review the linked package source code to understand the potential risk. Ensure the package is not malicious before proceeding. If you're unsure how to proceed, reach out to your security team or ask the Socket team for help at support@socket.dev.

    Suggestion: Find a package that does not violate your license policy or adjust your policy to allow this package's license.

    @@ -1046,7 +1101,7 @@ def security_comment_template(diff: Diff, config=None) -> str: