From 4b1ac096203e43743f1a6b6a6c2c4ace3ee846f0 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:57:47 -0400 Subject: [PATCH 1/3] Improve monorepo scan diagnostics --- README.md | 4 + docs/ci-cd.md | 218 ++++++++++++++++++++++++++++++++ docs/cli-reference.md | 34 ++++- 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 | 17 ++- 8 files changed, 520 insertions(+), 18 deletions(-) create mode 100644 tests/unit/test_scan_scope.py diff --git a/README.md b/README.md index cda9a40..5f978d5 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 061d18e..9fcdf39 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -71,6 +71,224 @@ 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. + +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; list a shared/root lockfile in every workspace it affects. + +```json +[ + { + "name": "", + "sub_paths": [""], + "watch_globs": [""] + } +] +``` + +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. + +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 c480084..c316f50 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -53,18 +53,26 @@ 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`. ### 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 +97,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 +159,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 @@ -373,7 +395,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/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index daab5fd..8abaa72 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1079,6 +1079,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], @@ -1121,6 +1145,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: @@ -1428,7 +1460,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: @@ -1449,13 +1484,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: @@ -1755,6 +1799,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: @@ -1769,9 +1817,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)}") @@ -1875,6 +1922,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: @@ -1910,7 +1965,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: @@ -1946,9 +2004,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 24e8e96..aeec837 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", @@ -834,6 +840,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) @@ -855,6 +866,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 da0efc6..d79f62f 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 0000000..bedd2c9 --- /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 39f59f5..7061f3c 100644 --- a/tests/unit/test_socketcli.py +++ b/tests/unit/test_socketcli.py @@ -2,11 +2,10 @@ import pytest -from socketsecurity.core.classes import Diff, Package from socketsecurity import socketcli +from socketsecurity.core.classes import Diff, Package from socketsecurity.socketcli import build_license_artifact_payload - # --------------------------------------------------------------------------- # Exit-code-on-api-error (flag-only, non-breaking for 2.3.x). # @@ -98,6 +97,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() From 464b1b8125256391c9faabfac5423fb1f9e22031 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 28 Aug 2026 17:59:39 -0400 Subject: [PATCH 2/3] Bump version to 2.6.9 --- CHANGELOG.md | 14 ++++++++++++++ pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- uv.lock | 4 ++-- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1526c36..fdaf19c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## 2.6.9 + +### 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.6.8 ### Changed: bump pinned @coana-tech/cli to 15.10.25 diff --git a/pyproject.toml b/pyproject.toml index f206c59..dad62f8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.8" +version = "2.6.9" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index cb041be..ba3db03 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.8' +__version__ = '2.6.9' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/uv.lock b/uv.lock index a1b3704..a15933c 100644 --- a/uv.lock +++ b/uv.lock @@ -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 = [ @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.8" +version = "2.6.9" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" }, From 405d52cdaf957ece2e85ff8c85e536e017e1e74d Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Fri, 28 Aug 2026 18:06:32 -0400 Subject: [PATCH 3/3] Bump version to 2.7.0 --- CHANGELOG.md | 2 +- pyproject.toml | 2 +- socketsecurity/__init__.py | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdaf19c..b6229c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## 2.6.9 +## 2.7.0 ### Changed: improve monorepo scan diagnostics and guidance diff --git a/pyproject.toml b/pyproject.toml index a44e2e7..ee43c15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.6.9" +version = "2.7.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index ba3db03..d72ecc6 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.6.9' +__version__ = '2.7.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/uv.lock b/uv.lock index 621b9c6..e759a6b 100644 --- a/uv.lock +++ b/uv.lock @@ -1282,7 +1282,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.6.9" +version = "2.7.0" source = { editable = "." } dependencies = [ { name = "beautifulsoup4" },