Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N>` 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).
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__author__ = 'socket.dev'
__version__ = '2.2.93'
__version__ = '2.3.0'
USER_AGENT = f'SocketPythonCLI/{__version__}'
30 changes: 30 additions & 0 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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="<int>",
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",
Expand Down
15 changes: 10 additions & 5 deletions socketsecurity/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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 = {
Expand Down
66 changes: 59 additions & 7 deletions socketsecurity/socketcli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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)

Expand All @@ -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:
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions tests/core/test_sdk_methods.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ def test_get_added_and_removed_packages(core):
"head",
"new",
use_types=True,
include_license_details="true",
)

# Verify the results
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/test_cli_config.py
Original file line number Diff line number Diff line change
@@ -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")
Expand Down
Loading
Loading