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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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 <id>` diffs against that full scan ID verbatim.
- `--base-commit-sha <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
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>`.

## SARIF use cases

### Full-scope reachable SARIF (grouped alerts)
Expand Down Expand Up @@ -204,8 +224,10 @@ Minimal pattern:
| `3` | Infrastructure or API error (timeout, network failure, unexpected error) |

`--exit-code-on-api-error <N>` remaps the infrastructure-error code (`3`) to any
value — e.g. a Buildkite `soft_fail` code, or `0` to swallow infra errors. Exit
`3` is a Socket convention, not an industry standard.
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

Expand Down
48 changes: 48 additions & 0 deletions docs/ci-cd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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
Expand Down
34 changes: 34 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>]
[--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]
Expand Down Expand Up @@ -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=<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 |
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ build-backend = "hatchling.build"

[project]
name = "socketsecurity"
version = "2.4.20"
version = "2.5.0"
requires-python = ">= 3.11"
license = {"file" = "LICENSE"}
dependencies = [
Expand Down
2 changes: 1 addition & 1 deletion socketsecurity/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
__author__ = 'socket.dev'
__version__ = '2.4.20'
__version__ = '2.5.0'
USER_AGENT = f'SocketPythonCLI/{__version__}'
29 changes: 29 additions & 0 deletions socketsecurity/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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="<id>",
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="<sha>",
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')
Expand Down
102 changes: 94 additions & 8 deletions socketsecurity/core/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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.
(
Expand Down
Loading
Loading