diff --git a/CHANGELOG.md b/CHANGELOG.md index e917ffa..abdfac7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,51 @@ # Changelog +## 2.3.0 + +### New: `--exit-code-on-api-error` + +Adds a configurable exit code for API / infrastructure failures (timeouts, +network errors, unexpected exceptions), so CI pipelines can distinguish them +from blocking security findings (exit `1`): + +``` +socketcli --exit-code-on-api-error 100 ... +``` + +Default is `3` (the code the CLI already used for these errors), so **default +behavior is unchanged** — the exit code only changes when you pass the flag. +Set it to a Buildkite `soft_fail` code, or to `0` to swallow infra errors. + +**Interaction to be aware of:** `--disable-blocking` forces exit `0` for *all* +outcomes and therefore overrides `--exit-code-on-api-error`. Use the new flag +*without* `--disable-blocking` if you want a custom infra-error code to take +effect. See the exit-code reference in the README. + +> A future `3.0` release is planned to make infrastructure errors exit non-zero +> even under `--disable-blocking` (so outages stop being silently swallowed). +> That is a breaking change and is intentionally **not** in this release. + +### New: commit message auto-truncation + +`--commit-message` values longer than 200 characters are now automatically +truncated before being sent to the API, preventing HTTP 413 errors from +oversized URL query parameters (common with AI-generated commit messages or +`$BUILDKITE_MESSAGE`). + +### Improved: Buildkite log formatting + +When running inside a Buildkite job (`BUILDKITE=true`), infrastructure errors +emit Buildkite log section markers (`^^^ +++` / `--- :warning:`) so the error +section auto-expands in the BK UI, plus a `soft_fail` hint. No effect on other +CI platforms. + +### Fixed + +- `--timeout` is now honored end-to-end: it was only applied to the local + `CliClient`, but the full-scan diff comparison uses the Socket SDK instance, + which was constructed without the CLI timeout and defaulted to 1200s. +- `--exclude-license-details` now propagates to the full-scan diff comparison + request (it was only applied to full-scan params / report URLs before). ## 2.2.93 - Bundled twelve Dependabot dependency updates: `urllib3`, `gitpython`, `python-dotenv`, `pytest`, `uv`, `cryptography`, `pygments`, `requests`, and `idna` (main app), plus `axios`, `requests`, and `flask` (e2e fixtures). `idna` 3.11 → 3.15 includes the fix for CVE-2026-45409. diff --git a/README.md b/README.md index 66f042c..a3eeb10 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,48 @@ Minimal pattern: SOCKET_SECURITY_API_TOKEN: ${{ secrets.SOCKET_SECURITY_API_TOKEN }} ``` +## Exit codes + +| Code | Meaning | +|------|---------| +| `0` | Clean scan — no blocking issues (or `--disable-blocking` set) | +| `1` | Blocking security finding(s) detected | +| `2` | Scan interrupted (SIGINT / Ctrl+C) | +| `3` | Infrastructure or API error (timeout, network failure, unexpected error) | + +`--exit-code-on-api-error ` remaps the infrastructure-error code (`3`) to any +value — e.g. a Buildkite `soft_fail` code, or `0` to swallow infra errors. Exit +`3` is a Socket convention, not an industry standard. + +### How these options interact + +The two flags that affect exit codes can cancel each other out, so the order of +precedence matters: + +- **`--disable-blocking` wins over everything.** It forces exit `0` for *all* + outcomes — security findings *and* infrastructure errors. If you set it, + `--exit-code-on-api-error` has no effect (you'll always get `0`). +- **`--exit-code-on-api-error` only applies when `--disable-blocking` is *not* + set.** It changes the infra-error code (and the generic-error code); it never + touches the security-finding code (`1`). + +So for the common "don't let Socket outages block my pipeline, but still fail on +real findings" goal, use `--exit-code-on-api-error` **without** `--disable-blocking`: + +```yaml +# Buildkite: soft-fail only on infrastructure errors, still block on findings +steps: + - label: ":lock: Socket Security Scan" + command: "socketcli --exit-code-on-api-error 100 ..." # NOT --disable-blocking + soft_fail: + - exit_status: 100 +``` + +Combining `--disable-blocking` with `--exit-code-on-api-error 100` would make the +scan exit `0` on *both* findings and outages — the `soft_fail: 100` rule would +never match, and real findings would stop blocking. That's usually not what you +want. + ## Common gotchas See [`docs/troubleshooting.md`](https://github.com/SocketDev/socket-python-cli/blob/main/docs/troubleshooting.md#common-gotchas). diff --git a/pyproject.toml b/pyproject.toml index 7a7577e..cf409eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.2.93" +version = "2.3.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index f5930c6..10f2993 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.2.93' +__version__ = '2.3.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index c048af2..7a262de 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -101,6 +101,7 @@ class CliConfig: pending_head: bool = False enable_diff: bool = False timeout: Optional[int] = 1200 + exit_code_on_api_error: int = 3 exclude_license_details: bool = False include_module_folders: bool = False repo_is_public: bool = False @@ -182,6 +183,19 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': if commit_message and commit_message.startswith('"') and commit_message.endswith('"'): commit_message = commit_message[1:-1] + # Truncate to avoid 413s from oversized URL query parameters. + # The API has no application-layer length validation on commit_message; + # the 413 originates from an infrastructure-layer URL length limit + # (nginx/Cloudflare). 200 chars chosen as a conservative ceiling given + # URL encoding can 2-3x raw character count. + MAX_COMMIT_MESSAGE_LENGTH = 200 + if commit_message and len(commit_message) > MAX_COMMIT_MESSAGE_LENGTH: + logging.debug( + f"commit_message truncated from {len(commit_message)} to " + f"{MAX_COMMIT_MESSAGE_LENGTH} characters to avoid API request size limits" + ) + commit_message = commit_message[:MAX_COMMIT_MESSAGE_LENGTH] + config_args = { 'api_token': api_token, 'repo': args.repo, @@ -219,6 +233,7 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'integration_type': args.integration, 'pending_head': args.pending_head, 'timeout': args.timeout, + 'exit_code_on_api_error': args.exit_code_on_api_error, 'exclude_license_details': args.exclude_license_details, 'include_module_folders': args.include_module_folders, 'repo_is_public': args.repo_is_public, @@ -802,6 +817,21 @@ def create_argument_parser() -> argparse.ArgumentParser: help="Timeout in seconds for API requests", required=False ) + advanced_group.add_argument( + "--exit-code-on-api-error", + dest="exit_code_on_api_error", + type=int, + default=3, + metavar="", + help=( + "Exit code to use when the CLI fails on an API or infrastructure error " + "(timeout, network failure, unexpected exception). Default: 3. Useful for " + "distinguishing infrastructure failures from security findings (exit 1) in " + "CI -- e.g. set to a Buildkite soft_fail code. NOTE: --disable-blocking " + "forces exit 0 for ALL outcomes and therefore overrides this flag; do not " + "combine the two if you want the custom code to take effect." + ) + ) advanced_group.add_argument( "--allow-unverified", action="store_true", diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index 1f488b2..99c3455 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -941,7 +941,8 @@ def get_license_text_via_purl(self, packages: dict[str, Package], batch_size: in def get_added_and_removed_packages( self, head_full_scan_id: str, - new_full_scan_id: str + new_full_scan_id: str, + include_license_details: bool = True ) -> Tuple[Dict[str, Package], Dict[str, Package], Dict[str, Package]]: """ Get packages that were added and removed between scans. @@ -958,12 +959,12 @@ def get_added_and_removed_packages( diff_start = time.time() try: diff_report = ( - self.sdk.fullscans.stream_diff - ( + self.sdk.fullscans.stream_diff( self.config.org_slug, head_full_scan_id, new_full_scan_id, - use_types=True + use_types=True, + include_license_details=str(include_license_details).lower() ).data ) except APIFailure as e: @@ -1175,7 +1176,11 @@ def create_new_diff( added_packages, removed_packages, packages - ) = self.get_added_and_removed_packages(head_full_scan_id, new_full_scan.id) + ) = self.get_added_and_removed_packages( + head_full_scan_id, + new_full_scan.id, + include_license_details=getattr(params, "include_license_details", True) + ) # Separate unchanged packages from added/removed for --strict-blocking support unchanged_packages = { diff --git a/socketsecurity/socketcli.py b/socketsecurity/socketcli.py index 26823cc..1849239 100644 --- a/socketsecurity/socketcli.py +++ b/socketsecurity/socketcli.py @@ -27,6 +27,37 @@ load_dotenv() +# Buildkite sets BUILDKITE=true in every job environment. Used to gate log +# section markers that would render as literal text on other CI platforms. +IS_BUILDKITE = os.getenv("BUILDKITE") == "true" + + +def _emit_infrastructure_error(message: str, include_traceback: bool = False) -> None: + """Emit a structured error for infrastructure/API failures. + + When running in Buildkite, wraps the error in log-section markers + (`^^^ +++` expands the section in the BK UI) and prints a soft_fail hint. + On every other platform it's a plain log.error so the markers don't leak + as literal text. This is presentation only -- it does not decide the exit + code (the caller does that, honoring --disable-blocking and + --exit-code-on-api-error). + """ + if IS_BUILDKITE: + print("^^^ +++", flush=True) + print("--- :warning: Socket infrastructure error", flush=True) + + log.error(message) + + if IS_BUILDKITE: + log.error( + "Tip: this is an infrastructure error, not a security finding. To keep it " + "from blocking the build, add a soft_fail rule for the CLI's API-error exit " + "code (default 3, or whatever you pass to --exit-code-on-api-error)." + ) + + if include_traceback: + traceback.print_exc() + def build_license_artifact_payload( diff: Diff, @@ -62,6 +93,23 @@ def _write_attribution_file(config, payload: dict) -> None: Core.save_file(config.license_file_name, json.dumps(payload, indent=2)) +DEFAULT_API_TIMEOUT = 1200 + + +def get_api_request_timeout(config: CliConfig) -> int: + return config.timeout if config.timeout is not None else DEFAULT_API_TIMEOUT + + +def build_socket_sdk(config: CliConfig) -> socketdev: + cli_user_agent_string = f"SocketPythonCLI/{config.version}" + return socketdev( + token=config.api_token, + timeout=get_api_request_timeout(config), + allow_unverified=config.allow_unverified, + user_agent=cli_user_agent_string + ) + + def cli(): try: main_code() @@ -73,12 +121,17 @@ def cli(): else: sys.exit(0) except Exception as error: - log.error("Unexpected error when running the cli") - log.error(error) - traceback.print_exc() config = CliConfig.from_args() # Get current config + _emit_infrastructure_error( + f"Unexpected error when running the CLI: {error}", + include_traceback=True, + ) + # --disable-blocking forces a clean exit for ALL outcomes (it takes + # precedence over --exit-code-on-api-error); otherwise infra/API errors + # exit with the configurable code (default 3), keeping them distinct + # from blocking security findings (exit 1). if not config.disable_blocking: - sys.exit(3) + sys.exit(config.exit_code_on_api_error) else: sys.exit(0) @@ -99,8 +152,7 @@ def main_code(): "1. Command line: --api-token YOUR_TOKEN\n" "2. Environment variable: SOCKET_SECURITY_API_TOKEN") sys.exit(3) - cli_user_agent_string = f"SocketPythonCLI/{config.version}" - sdk = socketdev(token=config.api_token, allow_unverified=config.allow_unverified, user_agent=cli_user_agent_string) + sdk = build_socket_sdk(config) # Suppress urllib3 InsecureRequestWarning when using --allow-unverified if config.allow_unverified: @@ -119,7 +171,7 @@ def main_code(): socket_config = SocketConfig( api_key=config.api_token, allow_unverified_ssl=config.allow_unverified, - timeout=config.timeout if config.timeout is not None else 1200 # Use CLI timeout if provided + timeout=get_api_request_timeout(config) ) log.debug("loaded socket_config") client = CliClient(socket_config) diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index bb096eb..fdcbef3 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -101,6 +101,7 @@ def test_get_added_and_removed_packages(core): "head", "new", use_types=True, + include_license_details="true", ) # Verify the results diff --git a/tests/unit/test_cli_config.py b/tests/unit/test_cli_config.py index 27801ec..39447c2 100644 --- a/tests/unit/test_cli_config.py +++ b/tests/unit/test_cli_config.py @@ -1,6 +1,45 @@ import pytest from socketsecurity.config import CliConfig + +class TestExitCodeOnApiError: + def test_default_is_3(self): + config = CliConfig.from_args(["--api-token", "test"]) + assert config.exit_code_on_api_error == 3 + + def test_custom_value(self): + config = CliConfig.from_args( + ["--api-token", "test", "--exit-code-on-api-error", "100"] + ) + assert config.exit_code_on_api_error == 100 + + def test_zero_value(self): + config = CliConfig.from_args( + ["--api-token", "test", "--exit-code-on-api-error", "0"] + ) + assert config.exit_code_on_api_error == 0 + + +class TestCommitMessageTruncation: + def test_passes_through_under_limit(self): + msg = "a normal short commit message" + config = CliConfig.from_args(["--api-token", "test", "--commit-message", msg]) + assert config.commit_message == msg + + def test_truncated_above_limit(self): + config = CliConfig.from_args( + ["--api-token", "test", "--commit-message", "a" * 250] + ) + assert config.commit_message == "a" * 200 + + def test_quote_strip_runs_before_truncation(self): + quoted = '"' + ("b" * 250) + '"' + config = CliConfig.from_args( + ["--api-token", "test", "--commit-message", quoted] + ) + assert config.commit_message == "b" * 200 + + class TestCliConfig: def test_api_token_from_env(self, monkeypatch): monkeypatch.setenv("SOCKET_SECURITY_API_KEY", "test-token") diff --git a/tests/unit/test_socketcli.py b/tests/unit/test_socketcli.py index e48788a..39f59f5 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -1,7 +1,103 @@ +import sys + +import pytest + from socketsecurity.core.classes import Diff, Package +from socketsecurity import socketcli from socketsecurity.socketcli import build_license_artifact_payload +# --------------------------------------------------------------------------- +# Exit-code-on-api-error (flag-only, non-breaking for 2.3.x). +# +# Default behavior is unchanged from prior releases: unexpected errors exit 3, +# and --disable-blocking forces exit 0 for everything. The flag only changes +# the code when explicitly set, and --disable-blocking still takes precedence. +# --------------------------------------------------------------------------- + + +def _run_cli_expecting_exit(monkeypatch, argv, boom=None): + def fail_main_code(): + raise (boom or RuntimeError("infra boom")) + + monkeypatch.setattr(socketcli, "main_code", fail_main_code) + monkeypatch.setattr(sys, "argv", argv) + with pytest.raises(SystemExit) as exc_info: + socketcli.cli() + return exc_info.value.code + + +def test_unexpected_error_exits_3_by_default(monkeypatch): + code = _run_cli_expecting_exit(monkeypatch, ["socketcli", "--api-token", "test"]) + assert code == 3 + + +def test_exit_code_on_api_error_remaps_failure(monkeypatch): + code = _run_cli_expecting_exit( + monkeypatch, + ["socketcli", "--api-token", "test", "--exit-code-on-api-error", "100"], + ) + assert code == 100 + + +def test_disable_blocking_overrides_exit_code_on_api_error(monkeypatch): + # The documented interaction: --disable-blocking forces exit 0 for ALL + # outcomes and therefore overrides --exit-code-on-api-error. A user who + # sets both gets 0, NOT 100 -- this guards against silently regressing + # that precedence (which would break the documented soft_fail guidance). + code = _run_cli_expecting_exit( + monkeypatch, + [ + "socketcli", "--api-token", "test", + "--exit-code-on-api-error", "100", + "--disable-blocking", + ], + ) + assert code == 0 + + +def test_keyboard_interrupt_still_exits_2(monkeypatch): + code = _run_cli_expecting_exit( + monkeypatch, ["socketcli", "--api-token", "test"], boom=KeyboardInterrupt() + ) + assert code == 2 + + +# --------------------------------------------------------------------------- +# Buildkite-aware infrastructure error formatting. +# --------------------------------------------------------------------------- + + +def test_emit_infra_error_no_buildkite_has_no_markers(monkeypatch, capsys, caplog): + monkeypatch.setattr(socketcli, "IS_BUILDKITE", False) + with caplog.at_level("ERROR", logger="socketcli"): + socketcli._emit_infrastructure_error("something failed") + out = capsys.readouterr().out + assert "^^^ +++" not in out + assert "--- :warning:" not in out + assert "soft_fail" not in "\n".join(r.getMessage() for r in caplog.records) + + +def test_emit_infra_error_buildkite_emits_markers(monkeypatch, capsys, caplog): + monkeypatch.setattr(socketcli, "IS_BUILDKITE", True) + with caplog.at_level("ERROR", logger="socketcli"): + socketcli._emit_infrastructure_error("something failed") + out = capsys.readouterr().out + assert "^^^ +++" in out + assert "--- :warning: Socket infrastructure error" in out + assert "soft_fail" in "\n".join(r.getMessage() for r in caplog.records) + + +def test_emit_infra_error_traceback_gated(monkeypatch, capsys): + monkeypatch.setattr(socketcli, "IS_BUILDKITE", False) + try: + raise ValueError("boom") + except ValueError: + socketcli._emit_infrastructure_error("wrapped", include_traceback=True) + err = capsys.readouterr().err + assert "Traceback" in err and "ValueError: boom" in err + + def test_build_license_artifact_payload_without_packages_returns_empty_dict(): diff = Diff() diff --git a/uv.lock b/uv.lock index facecef..9df9b09 100644 --- a/uv.lock +++ b/uv.lock @@ -1168,7 +1168,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.2.93" +version = "2.3.0" source = { editable = "." } dependencies = [ { name = "bs4" },