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/4] 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 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..9fcdf397 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 c4800841..c316f503 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 daab5fdd..8abaa725 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 24e8e966..aeec8376 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 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 39f59f5b..7061f3cd 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/4] 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 1526c360..fdaf19c0 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 f206c593..dad62f8a 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 cb041be8..ba3db034 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 a1b37042..a15933c8 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/4] 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 fdaf19c0..b6229c30 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 a44e2e71..ee43c155 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 ba3db034..d72ecc6e 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 621b9c67..e759a6bd 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" }, From 45a8d3a4fef3d2c9a37f39ceab16302f587e55b7 Mon Sep 17 00:00:00 2001 From: lelia <2418071+lelia@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:40:38 -0400 Subject: [PATCH 4/4] 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) --- docs/ci-cd.md | 6 ++++++ docs/cli-reference.md | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/docs/ci-cd.md b/docs/ci-cd.md index dc8d4105..968799b5 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -79,6 +79,12 @@ 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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index c8e65832..3f38a80a 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -70,6 +70,47 @@ 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 several directories that belong to one logical application:**