diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f165c3..ee8851e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## 2.5.0 + +### Added: `--base-scan-id` / `--base-commit-sha` diff baseline overrides + +- New mutually exclusive flags to control which full scan a diff is compared + against, instead of always using the repository's latest head scan: + - `--base-scan-id ` diffs against that full scan ID verbatim. + - `--base-commit-sha ` diffs against the most recent full scan created + from that commit — e.g. the PR's merge base from + `git merge-base origin/main HEAD` — so PR diffs are not polluted by + default-branch commits the PR never branched from. +- A `--base-commit-sha` with no matching full scan is a hard error (exit code 3, + or `--exit-code-on-api-error`; exit 0 with `--disable-blocking`) rather than a + silent fallback to the head scan, since diffing against the wrong baseline + misreports which alerts a PR introduces. +- Both flags are also settable via `--config` files (`base_scan_id`, + `base_commit_sha`). +- **Requirement:** `--base-commit-sha` looks up an existing scan — it does not + create one. Using it requires CI to run `socketcli` on every commit that lands + on the default branch; see the "Diffing against the merge base" note in + `docs/cli-reference.md` for the failure modes and a backfill pattern. + ## 2.4.20 ### Changed: bump pinned @coana-tech/cli to 15.8.8 diff --git a/README.md b/README.md index a3eeb10..cda9a40 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,26 @@ socketcli --target-path . socketcli --enable-gitlab-security --gitlab-security-file gl-dependency-scanning-report.json ``` +### PR scan diffed against the merge base + +By default, PR scans are diffed against the repository's latest head scan. To diff against +the exact commit your PR branched from instead, pass the merge base as the baseline: + +```bash +BASE_SHA=$(git merge-base origin/main HEAD) +socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" +``` + +> **Requirement:** `--base-commit-sha` only works if Socket already has a full scan for that +> exact commit. In practice this means your CI must run `socketcli` on **every commit that +> lands on your default branch** — not just some of them. If merges can land without a scan +> (skipped/canceled builds, `[skip ci]`, path-filtered pipelines), the PR scan will fail with +> exit code 3 rather than silently diff against the wrong baseline. See +> [`docs/cli-reference.md`](https://github.com/SocketDev/socket-python-cli/blob/main/docs/cli-reference.md) +> for the full requirements and a backfill pattern that makes PR jobs self-sufficient. + +A specific full scan ID also works: `--base-scan-id `. + ## SARIF use cases ### Full-scope reachable SARIF (grouped alerts) @@ -204,8 +224,10 @@ Minimal pattern: | `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. +value — e.g. a Buildkite +[`soft_fail`](https://buildkite.com/docs/pipelines/configure/step-types/command-step) +code, or `0` to swallow infra errors. Exit `3` is a Socket convention, not an +industry standard. ### How these options interact diff --git a/docs/ci-cd.md b/docs/ci-cd.md index edf3a4e..66193f3 100644 --- a/docs/ci-cd.md +++ b/docs/ci-cd.md @@ -81,6 +81,54 @@ steps: SOCKET_SECURITY_API_TOKEN: "${SOCKET_SECURITY_API_TOKEN}" ``` +#### Merge-base baselines in Buildkite (dynamic pipelines) + +Notes for using `--base-commit-sha` (see the +[merge-base note in the CLI reference](cli-reference.md#pull-request-and-commit)) +when your steps are emitted by a +[dynamic pipeline](https://buildkite.com/docs/pipelines/configure/dynamic-pipelines) +generator rather than a static YAML file: + +- **Compute the merge base at generation time, not step time.** The generator runs + with a full checkout; step agents may have shallow or fresh clones where + `git merge-base` fails or needs an extra fetch. Resolve it once in the generator and + bake it into the emitted step's `env`. Diff against the PR's *target* branch, which + isn't always the default branch (see Buildkite's + [environment variables](https://buildkite.com/docs/pipelines/configure/environment-variables)): + + ```shell + TARGET="${BUILDKITE_PULL_REQUEST_BASE_BRANCH:-$BUILDKITE_PIPELINE_DEFAULT_BRANCH}" + BASE_SHA=$(git merge-base "origin/${TARGET}" HEAD) + ``` + +- **Emit the backfill step conditionally from the generator.** The generator is the + natural place for the "does a baseline scan exist?" check + (`GET /orgs/{org}/full-scans?repo=&commit_hash=$BASE_SHA&per_page=1`): only + emit the baseline-scan step when it returns nothing. The emitted pipeline then shows + in the UI whether a backfill will run. + +- **Keep the backfill inside one command step.** The checkout-base → scan → + checkout-PR sequence must not be split across steps — steps can land on different + agents with different checkouts. Prefer + [`git worktree`](https://git-scm.com/docs/git-worktree) over mutating the step's + checkout: `git worktree add /tmp/socket-base "$BASE_SHA"` then + `socketcli --target-path /tmp/socket-base --branch "$TARGET" --disable-blocking`. + +- **Soft-fail infra errors, not findings.** A missing baseline (or any API error) + exits with code 3 (`--exit-code-on-api-error` to change it); real findings exit 1. + [`soft_fail: [{exit_status: 3}]`](https://buildkite.com/docs/pipelines/configure/step-types/command-step) + on the PR scan step keeps infra errors from blocking merges while security findings + still do. + +- **["Cancel intermediate builds"](https://buildkite.com/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds) + on the default branch is the main source of baseline gaps.** Canceled builds never + scan their commit, so merge-base lookups for PRs based on those commits fail. The + conditional backfill step above is the remedy; there is no per-step exemption from + build cancellation in Buildkite. If you need strict scan-once semantics for + concurrent backfills of the same merge base, serialize the backfill step with a + [concurrency group](https://buildkite.com/docs/pipelines/configure/workflows/controlling-concurrency) + keyed on the merge-base SHA. + ### GitLab CI ```yaml diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 00828d6..ca7be23 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -146,6 +146,7 @@ This will simultaneously generate: socketcli [-h] [--api-token API_TOKEN] [--repo REPO] [--workspace WORKSPACE] [--repo-is-public] [--branch BRANCH] [--integration {api,github,gitlab,azure,bitbucket}] [--config ] [--owner OWNER] [--pr-number PR_NUMBER] [--commit-message COMMIT_MESSAGE] [--commit-sha COMMIT_SHA] [--committers [COMMITTERS ...]] + [--base-scan-id BASE_SCAN_ID | --base-commit-sha BASE_COMMIT_SHA] [--target-path TARGET_PATH] [--sbom-file SBOM_FILE] [--license-file-name LICENSE_FILE_NAME] [--save-submitted-files-list SAVE_SUBMITTED_FILES_LIST] [--save-manifest-tar SAVE_MANIFEST_TAR] [--files FILES] [--sub-path SUB_PATH] [--workspace-name WORKSPACE_NAME] [--excluded-ecosystems EXCLUDED_ECOSYSTEMS] [--exclude-paths EXCLUDE_PATHS] [--include-dirs INCLUDE_DIRS] [--default-branch] [--pending-head] [--generate-license] [--enable-debug] @@ -191,6 +192,39 @@ If you don't want to provide the Socket API Token every time then you can use th | `--pr-number` | False | "0" | Pull request number | | `--commit-message` | False | *auto* | Commit message (auto-detected from git) | | `--commit-sha` | False | *auto* | Commit SHA (auto-detected from git) | +| `--base-scan-id` | False | | Full scan ID to diff against, overriding the repository's head scan as the baseline. Mutually exclusive with `--base-commit-sha` | +| `--base-commit-sha`| False | | Commit SHA to diff against, overriding the repository's head scan as the baseline. The most recent full scan for that commit is used; the CLI errors (exit code 3, or `--exit-code-on-api-error`) if no scan exists for it. Mutually exclusive with `--base-scan-id` | + +> **Diffing against the merge base** — by default, PR scans are diffed against the repository's *latest* head scan, which may include newer default-branch commits than your PR branched from. To diff against the exact commit your PR is based on, compute the merge base and pass it as the baseline: +> +> ```shell +> BASE_SHA=$(git merge-base origin/main HEAD) +> socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" +> ``` +> +> **Requirement: a full scan must already exist for the merge-base commit.** `--base-commit-sha` does not create a scan of that commit; it looks up an existing one. That lookup only succeeds if your CI runs `socketcli` on **every commit that lands on your default branch** — every merge and direct push, not just periodic or latest-only scans. Common ways commits slip through without a scan: +> +> - CI settings that cancel or skip intermediate builds when newer commits land (e.g. Buildkite's ["cancel intermediate builds"](https://buildkite.com/docs/pipelines/configure/canceling-builds#cancel-running-intermediate-builds)) +> - `[skip ci]` commits, path-filtered pipelines, or failed/canceled scan steps +> - merge-base commits that predate your Socket rollout +> +> If no scan exists for the commit, the CLI **fails** (exit code 3, or your `--exit-code-on-api-error` value; exit 0 with `--disable-blocking`) instead of silently falling back to the head scan — a wrong baseline would misreport which alerts the PR introduces. Don't adopt this flag without default-branch scan coverage in place; you'll fail PR builds on lookup misses. +> +> **Backfill pattern** — if your default-branch coverage has gaps, the PR job can create the missing baseline itself before scanning: +> +> ```shell +> BASE_SHA=$(git merge-base origin/main HEAD) +> # Create the baseline only if Socket doesn't have one for this commit yet +> # (check: GET /orgs/{org}/full-scans?repo=&commit_hash=$BASE_SHA&per_page=1) +> git checkout "$BASE_SHA" +> socketcli --branch main --disable-blocking +> git checkout - +> socketcli --pr-number 123 --base-commit-sha "$BASE_SHA" +> ``` +> +> Run the baseline step with `--disable-blocking` (findings on the default branch must not fail the PR job) and an explicit `--branch`, since branch auto-detection is unreliable at a detached HEAD. +> +> Buildkite users with dynamically generated pipelines: see [Merge-base baselines in Buildkite](ci-cd.md#merge-base-baselines-in-buildkite-dynamic-pipelines) for generation-time vs. step-time guidance. #### Path and File | Parameter | Required | Default | Description | diff --git a/pyproject.toml b/pyproject.toml index 52abf4b..716c413 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "hatchling.build" [project] name = "socketsecurity" -version = "2.4.20" +version = "2.5.0" requires-python = ">= 3.11" license = {"file" = "LICENSE"} dependencies = [ diff --git a/socketsecurity/__init__.py b/socketsecurity/__init__.py index 7f863af..f44ed53 100644 --- a/socketsecurity/__init__.py +++ b/socketsecurity/__init__.py @@ -1,3 +1,3 @@ __author__ = 'socket.dev' -__version__ = '2.4.20' +__version__ = '2.5.0' USER_AGENT = f'SocketPythonCLI/{__version__}' diff --git a/socketsecurity/config.py b/socketsecurity/config.py index 79495fc..2654244 100644 --- a/socketsecurity/config.py +++ b/socketsecurity/config.py @@ -119,6 +119,8 @@ class CliConfig: scm: str = "api" sbom_file: Optional[str] = None commit_sha: str = "" + base_scan_id: Optional[str] = None + base_commit_sha: Optional[str] = None generate_license: bool = False enable_debug: bool = False allow_unverified: bool = False @@ -265,6 +267,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': 'scm': args.scm, 'sbom_file': args.sbom_file, 'commit_sha': args.commit_sha, + 'base_scan_id': args.base_scan_id, + 'base_commit_sha': args.base_commit_sha, 'generate_license': args.generate_license, 'enable_debug': args.enable_debug, 'enable_diff': args.enable_diff, @@ -406,6 +410,12 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig': logging.error("--workspace-name requires --sub-path to be specified") exit(1) + # argparse only enforces the mutually exclusive group for real CLI args; + # this also catches both values arriving via a --config file. + if args.base_scan_id and args.base_commit_sha: + logging.error("--base-scan-id and --base-commit-sha are mutually exclusive") + exit(1) + if args.sarif_scope == "full" and not args.reach: logging.error("--sarif-scope full requires --reach to be specified") exit(1) @@ -560,6 +570,25 @@ def create_argument_parser() -> argparse.ArgumentParser: help="Committer for the commit (comma separated)", nargs="*" ) + base_scan_group = pr_group.add_mutually_exclusive_group() + base_scan_group.add_argument( + "--base-scan-id", + dest="base_scan_id", + metavar="", + default=None, + help="Full scan ID to diff the new scan against, overriding the repository's " + "head scan as the baseline. Mutually exclusive with --base-commit-sha." + ) + base_scan_group.add_argument( + "--base-commit-sha", + dest="base_commit_sha", + metavar="", + default=None, + help="Commit SHA to diff the new scan against, overriding the repository's head " + "scan as the baseline. The most recent full scan matching this commit (e.g. " + "the merge base from 'git merge-base origin/main HEAD') is used; the CLI " + "errors if no scan exists for it. Mutually exclusive with --base-scan-id." + ) # Path and File options path_group = parser.add_argument_group('Path and File') diff --git a/socketsecurity/core/__init__.py b/socketsecurity/core/__init__.py index a8cef92..a372de4 100644 --- a/socketsecurity/core/__init__.py +++ b/socketsecurity/core/__init__.py @@ -1154,6 +1154,94 @@ def get_head_scan_for_repo(self, repo_slug: str) -> str: repo_info = self.get_repo_info(repo_slug) return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None + def get_full_scan_id_by_commit( + self, + repo_slug: str, + commit_sha: str, + workspace: Optional[str] = None, + scan_type: Optional[str] = None + ) -> Optional[str]: + """ + Finds the most recent full scan for a repository + commit SHA. + + Used by --base-commit-sha to resolve the diff baseline (e.g. the merge-base + commit of a PR). When the same commit was scanned more than once, the newest + scan wins. + + Args: + repo_slug: Repository slug the scan belongs to + commit_sha: Commit SHA the scan was created from + workspace: Socket workspace the scan belongs to, if any + scan_type: Socket scan type to match, if any + + Returns: + Full scan ID if one exists for that commit, None otherwise + """ + query_params = { + "repo": repo_slug, + "commit_hash": commit_sha, + "sort": "created_at", + "direction": "desc", + "per_page": 1, + } + if workspace: + query_params["workspace"] = workspace + if scan_type: + query_params["scan_type"] = scan_type + + response = self.sdk.fullscans.get( + self.config.org_slug, + query_params, + ) + results = response.get("results") if isinstance(response, dict) else None + if not results: + return None + return results[0].get("id") + + def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]: + """ + Resolves the baseline full scan ID to diff a new scan against. + + Priority: --base-scan-id (used verbatim), then --base-commit-sha (newest + full scan for that commit), then the repository's current head scan. A + --base-commit-sha with no matching full scan is a hard error rather than a + silent fallback to the head scan, because diffing against the wrong + baseline silently misreports which alerts a PR introduces. + + Returns: + Full scan ID to use as the diff baseline, or None when the repository + 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)") + return self.cli_config.base_scan_id + + if self.cli_config and self.cli_config.base_commit_sha: + commit_sha = self.cli_config.base_commit_sha + scan_id = self.get_full_scan_id_by_commit( + params.repo, + commit_sha, + workspace=params.workspace, + scan_type=params.scan_type, + ) + if scan_id is None: + log.error( + f"No full scan found for commit {commit_sha} in repo {params.repo} " + "(--base-commit-sha). Ensure a scan was created for that commit " + "(e.g. the CLI runs on default-branch pushes), or pass " + "--base-scan-id instead." + ) + 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)") + return scan_id + + try: + return self.get_head_scan_for_repo(params.repo) + except APIResourceNotFound: + return None + @staticmethod def update_package_values(pkg: Package) -> Package: pkg.purl = f"{pkg.name}@{pkg.version}" @@ -1242,8 +1330,8 @@ def get_added_and_removed_packages( OVERWRITES whatever the diff embedded, before anything reads it. Either way the embedded license payload is dead weight, and on large dependency trees it inflated the diff response past ~2.3MB - and truncated it mid-string, crashing ``response.json()`` - (CE-224, customer: Tremendous). Defaulting to ``False`` keeps the + and truncated it mid-string, crashing ``response.json()``. + Defaulting to ``False`` keeps the diff lean with zero change to any output artifact. The parameter is retained as an explicit override seam, not wired to the ``--exclude-license-details`` user flag (which still governs the @@ -1388,11 +1476,9 @@ def create_new_diff( log.info("No supported manifest files found - creating empty scan for diff comparison") scan_files = Core.empty_head_scan_file() - try: - # Get head scan ID - head_full_scan_id = self.get_head_scan_for_repo(params.repo) - except APIResourceNotFound: - head_full_scan_id = None + # Resolve the baseline scan: --base-scan-id / --base-commit-sha override + # the repository's head scan (None only when the repo has no head scan yet). + head_full_scan_id = self.resolve_base_full_scan_id(params) # If no head scan exists, create an empty baseline scan if head_full_scan_id is None: @@ -1475,7 +1561,7 @@ def create_new_diff( # (the --exclude-license-details user flag) into the diff request. The # diff path never consumes embedded license data (see # get_added_and_removed_packages docstring), so requesting it only bloats - # the response and risks the CE-224 truncation crash on large repos. The + # the response and risks the truncation crash on large repos. The # user flag still controls the dashboard report URL below; it just no # longer gates this internal diff payload. ( diff --git a/tests/core/test_sdk_methods.py b/tests/core/test_sdk_methods.py index 2cad8e5..9b1ce44 100644 --- a/tests/core/test_sdk_methods.py +++ b/tests/core/test_sdk_methods.py @@ -1,6 +1,7 @@ import pytest from socketdev.fullscans import FullScanParams +from socketsecurity.config import CliConfig from socketsecurity.core import Core from socketsecurity.core.socket_config import SocketConfig @@ -10,6 +11,23 @@ def core(mock_sdk_with_responses): config = SocketConfig(api_key="test_key") return Core(config=config, sdk=mock_sdk_with_responses) + +def make_cli_config(*extra_args): + return CliConfig.from_args(["--api-token", "test-token", "--repo", "test", *extra_args]) + + +def make_full_scan_params(**overrides): + values = { + "repo": "test", + "branch": "main", + "commit_hash": "head123", + "scan_type": "socket", + "workspace": None, + } + values.update(overrides) + return FullScanParams(**values) + + def test_get_repo_info(core, mock_sdk_with_responses): """Test getting repository information""" repo_info = core.get_repo_info("test") @@ -44,6 +62,119 @@ def test_get_head_scan_for_repo_no_head(core, mock_sdk_with_responses): head_scan_id = core.get_head_scan_for_repo("no-head") assert head_scan_id is None +def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses): + """Looks up the newest full scan for a repo + commit via the list endpoint""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "base-scan-id", "commit_hash": "abc123"}], + "nextPage": None, + } + + scan_id = core.get_full_scan_id_by_commit("test", "abc123") + + assert scan_id == "base-scan-id" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "commit_hash": "abc123", + "sort": "created_at", + "direction": "desc", + "per_page": 1, + }, + ) + + +def test_get_full_scan_id_by_commit_scopes_to_workspace_and_scan_type(core, mock_sdk_with_responses): + """Looks up a baseline scan from the same workspace and scan type as the new scan""" + mock_sdk_with_responses.fullscans.get.return_value = { + "results": [{"id": "workspace-reach-base", "commit_hash": "abc123"}], + "nextPage": None, + } + + scan_id = core.get_full_scan_id_by_commit( + "test", + "abc123", + workspace="customer-a", + scan_type="socket_tier1", + ) + + assert scan_id == "workspace-reach-base" + mock_sdk_with_responses.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "commit_hash": "abc123", + "sort": "created_at", + "direction": "desc", + "per_page": 1, + "workspace": "customer-a", + "scan_type": "socket_tier1", + }, + ) + + +def test_get_full_scan_id_by_commit_not_found(core, mock_sdk_with_responses): + """No scan for the commit returns None (empty results and SDK error dict)""" + mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None} + assert core.get_full_scan_id_by_commit("test", "abc123") is None + + mock_sdk_with_responses.fullscans.get.return_value = {} + assert core.get_full_scan_id_by_commit("test", "abc123") is None + +def test_resolve_base_full_scan_id_defaults_to_head_scan(core): + """Without base overrides the repository head scan is the baseline""" + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head" + +def test_resolve_base_full_scan_id_uses_base_scan_id(core): + """--base-scan-id is used verbatim, without touching the repo endpoint""" + core.cli_config = make_cli_config("--base-scan-id", "explicit-base") + + assert core.resolve_base_full_scan_id(make_full_scan_params()) == "explicit-base" + core.sdk.repos.repo.assert_not_called() + +def test_resolve_base_full_scan_id_uses_base_commit_sha(core): + """--base-commit-sha resolves through the full-scans list endpoint""" + core.cli_config = make_cli_config("--base-commit-sha", "abc123") + core.sdk.fullscans.get.return_value = { + "results": [{"id": "merge-base-scan"}], + "nextPage": None, + } + + params = make_full_scan_params(workspace="customer-a", scan_type="socket_tier1") + + assert core.resolve_base_full_scan_id(params) == "merge-base-scan" + core.sdk.repos.repo.assert_not_called() + core.sdk.fullscans.get.assert_called_once_with( + core.config.org_slug, + { + "repo": "test", + "commit_hash": "abc123", + "sort": "created_at", + "direction": "desc", + "per_page": 1, + "workspace": "customer-a", + "scan_type": "socket_tier1", + }, + ) + +def test_resolve_base_full_scan_id_commit_sha_not_found_exits(core): + """A --base-commit-sha with no scan is a hard error (exit_code_on_api_error)""" + core.cli_config = make_cli_config("--base-commit-sha", "abc123") + core.sdk.fullscans.get.return_value = {"results": [], "nextPage": None} + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(make_full_scan_params()) + assert exc_info.value.code == core.cli_config.exit_code_on_api_error + +def test_resolve_base_full_scan_id_commit_sha_not_found_disable_blocking(core): + """--disable-blocking keeps the missing-base error from failing the build""" + core.cli_config = make_cli_config("--base-commit-sha", "abc123", "--disable-blocking") + core.sdk.fullscans.get.return_value = {"results": [], "nextPage": None} + + with pytest.raises(SystemExit) as exc_info: + core.resolve_base_full_scan_id(make_full_scan_params()) + assert exc_info.value.code == 0 + def test_get_full_scan(core, mock_sdk_with_responses, head_scan_metadata, head_scan_stream): """Test getting an existing full scan""" full_scan = core.get_full_scan("head") @@ -98,7 +229,7 @@ def test_get_added_and_removed_packages(core): # Verify SDK was called correctly. # include_license_details defaults to "false": the diff path never consumes # embedded license data (license artifacts come from the PURL endpoint), so - # requesting it only bloats the response and risks the CE-224 truncation + # requesting it only bloats the response and risks the truncation # crash on large repos. core.sdk.fullscans.stream_diff.assert_called_once_with( core.config.org_slug, diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 717e302..c1443ad 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -166,6 +166,57 @@ def test_config_file_json_sets_defaults(self, tmp_path): assert config.sarif_reachability == "reachable" +class TestBaseScanFlags: + """Tests for the --base-scan-id / --base-commit-sha diff baseline overrides.""" + + BASE_ARGS = ["--api-token", "test-token", "--repo", "test-repo"] + + def test_defaults_are_none(self): + config = CliConfig.from_args(self.BASE_ARGS) + assert config.base_scan_id is None + assert config.base_commit_sha is None + + def test_base_scan_id_parses(self): + config = CliConfig.from_args(self.BASE_ARGS + ["--base-scan-id", "scan-123"]) + assert config.base_scan_id == "scan-123" + assert config.base_commit_sha is None + + def test_base_commit_sha_parses(self): + config = CliConfig.from_args(self.BASE_ARGS + ["--base-commit-sha", "abc123def"]) + assert config.base_commit_sha == "abc123def" + assert config.base_scan_id is None + + def test_flags_are_mutually_exclusive(self): + """argparse rejects both flags on the command line (exit code 2).""" + with pytest.raises(SystemExit) as exc_info: + CliConfig.from_args( + self.BASE_ARGS + ["--base-scan-id", "scan-123", "--base-commit-sha", "abc123def"] + ) + assert exc_info.value.code == 2 + + def test_config_file_values_are_mutually_exclusive(self, tmp_path): + """Both values arriving via --config bypass argparse's group; from_args catches it.""" + config_path = tmp_path / "socketcli.toml" + config_path.write_text( + "[socketcli]\n" + "base_scan_id = \"scan-123\"\n" + "base_commit_sha = \"abc123def\"\n", + encoding="utf-8", + ) + with pytest.raises(SystemExit) as exc_info: + CliConfig.from_args(self.BASE_ARGS + ["--config", str(config_path)]) + assert exc_info.value.code == 1 + + def test_config_file_sets_base_commit_sha(self, tmp_path): + config_path = tmp_path / "socketcli.toml" + config_path.write_text( + "[socketcli]\nbase_commit_sha = \"abc123def\"\n", + encoding="utf-8", + ) + config = CliConfig.from_args(self.BASE_ARGS + ["--config", str(config_path)]) + assert config.base_commit_sha == "abc123def" + + class TestReachAlignmentFlags: """Tests for the reachability flag/default alignment with the Node CLI.""" diff --git a/uv.lock b/uv.lock index 6805666..ba657c2 100644 --- a/uv.lock +++ b/uv.lock @@ -1283,7 +1283,7 @@ wheels = [ [[package]] name = "socketsecurity" -version = "2.4.20" +version = "2.5.0" source = { editable = "." } dependencies = [ { name = "brotli", marker = "platform_python_implementation == 'CPython'" },