Skip to content

Commit 78a713a

Browse files
committed
add --base-scan-id / --base-commit-sha diff baseline overrides
Signed-off-by: lelia <2418071+lelia@users.noreply.github.com>
1 parent 12f97fb commit 78a713a

4 files changed

Lines changed: 306 additions & 9 deletions

File tree

socketsecurity/config.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,8 @@ class CliConfig:
119119
scm: str = "api"
120120
sbom_file: Optional[str] = None
121121
commit_sha: str = ""
122+
base_scan_id: Optional[str] = None
123+
base_commit_sha: Optional[str] = None
122124
generate_license: bool = False
123125
enable_debug: bool = False
124126
allow_unverified: bool = False
@@ -265,6 +267,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
265267
'scm': args.scm,
266268
'sbom_file': args.sbom_file,
267269
'commit_sha': args.commit_sha,
270+
'base_scan_id': args.base_scan_id,
271+
'base_commit_sha': args.base_commit_sha,
268272
'generate_license': args.generate_license,
269273
'enable_debug': args.enable_debug,
270274
'enable_diff': args.enable_diff,
@@ -406,6 +410,12 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
406410
logging.error("--workspace-name requires --sub-path to be specified")
407411
exit(1)
408412

413+
# argparse only enforces the mutually exclusive group for real CLI args;
414+
# this also catches both values arriving via a --config file.
415+
if args.base_scan_id and args.base_commit_sha:
416+
logging.error("--base-scan-id and --base-commit-sha are mutually exclusive")
417+
exit(1)
418+
409419
if args.sarif_scope == "full" and not args.reach:
410420
logging.error("--sarif-scope full requires --reach to be specified")
411421
exit(1)
@@ -560,6 +570,25 @@ def create_argument_parser() -> argparse.ArgumentParser:
560570
help="Committer for the commit (comma separated)",
561571
nargs="*"
562572
)
573+
base_scan_group = pr_group.add_mutually_exclusive_group()
574+
base_scan_group.add_argument(
575+
"--base-scan-id",
576+
dest="base_scan_id",
577+
metavar="<id>",
578+
default=None,
579+
help="Full scan ID to diff the new scan against, overriding the repository's "
580+
"head scan as the baseline. Mutually exclusive with --base-commit-sha."
581+
)
582+
base_scan_group.add_argument(
583+
"--base-commit-sha",
584+
dest="base_commit_sha",
585+
metavar="<sha>",
586+
default=None,
587+
help="Commit SHA to diff the new scan against, overriding the repository's head "
588+
"scan as the baseline. The most recent full scan matching this commit (e.g. "
589+
"the merge base from 'git merge-base origin/main HEAD') is used; the CLI "
590+
"errors if no scan exists for it. Mutually exclusive with --base-scan-id."
591+
)
563592

564593
# Path and File options
565594
path_group = parser.add_argument_group('Path and File')

socketsecurity/core/__init__.py

Lines changed: 94 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1154,6 +1154,94 @@ def get_head_scan_for_repo(self, repo_slug: str) -> str:
11541154
repo_info = self.get_repo_info(repo_slug)
11551155
return repo_info.head_full_scan_id if repo_info.head_full_scan_id else None
11561156

1157+
def get_full_scan_id_by_commit(
1158+
self,
1159+
repo_slug: str,
1160+
commit_sha: str,
1161+
workspace: Optional[str] = None,
1162+
scan_type: Optional[str] = None
1163+
) -> Optional[str]:
1164+
"""
1165+
Finds the most recent full scan for a repository + commit SHA.
1166+
1167+
Used by --base-commit-sha to resolve the diff baseline (e.g. the merge-base
1168+
commit of a PR). When the same commit was scanned more than once, the newest
1169+
scan wins.
1170+
1171+
Args:
1172+
repo_slug: Repository slug the scan belongs to
1173+
commit_sha: Commit SHA the scan was created from
1174+
workspace: Socket workspace the scan belongs to, if any
1175+
scan_type: Socket scan type to match, if any
1176+
1177+
Returns:
1178+
Full scan ID if one exists for that commit, None otherwise
1179+
"""
1180+
query_params = {
1181+
"repo": repo_slug,
1182+
"commit_hash": commit_sha,
1183+
"sort": "created_at",
1184+
"direction": "desc",
1185+
"per_page": 1,
1186+
}
1187+
if workspace:
1188+
query_params["workspace"] = workspace
1189+
if scan_type:
1190+
query_params["scan_type"] = scan_type
1191+
1192+
response = self.sdk.fullscans.get(
1193+
self.config.org_slug,
1194+
query_params,
1195+
)
1196+
results = response.get("results") if isinstance(response, dict) else None
1197+
if not results:
1198+
return None
1199+
return results[0].get("id")
1200+
1201+
def resolve_base_full_scan_id(self, params: FullScanParams) -> Optional[str]:
1202+
"""
1203+
Resolves the baseline full scan ID to diff a new scan against.
1204+
1205+
Priority: --base-scan-id (used verbatim), then --base-commit-sha (newest
1206+
full scan for that commit), then the repository's current head scan. A
1207+
--base-commit-sha with no matching full scan is a hard error rather than a
1208+
silent fallback to the head scan, because diffing against the wrong
1209+
baseline silently misreports which alerts a PR introduces.
1210+
1211+
Returns:
1212+
Full scan ID to use as the diff baseline, or None when the repository
1213+
has no head scan yet (caller creates an empty baseline scan).
1214+
"""
1215+
if self.cli_config and self.cli_config.base_scan_id:
1216+
log.info(f"Using full scan {self.cli_config.base_scan_id} as diff baseline (--base-scan-id)")
1217+
return self.cli_config.base_scan_id
1218+
1219+
if self.cli_config and self.cli_config.base_commit_sha:
1220+
commit_sha = self.cli_config.base_commit_sha
1221+
scan_id = self.get_full_scan_id_by_commit(
1222+
params.repo,
1223+
commit_sha,
1224+
workspace=params.workspace,
1225+
scan_type=params.scan_type,
1226+
)
1227+
if scan_id is None:
1228+
log.error(
1229+
f"No full scan found for commit {commit_sha} in repo {params.repo} "
1230+
"(--base-commit-sha). Ensure a scan was created for that commit "
1231+
"(e.g. the CLI runs on default-branch pushes), or pass "
1232+
"--base-scan-id instead."
1233+
)
1234+
if self.cli_config.disable_blocking:
1235+
sys.exit(0)
1236+
sys.exit(self.cli_config.exit_code_on_api_error)
1237+
log.info(f"Using full scan {scan_id} (commit {commit_sha}) as diff baseline (--base-commit-sha)")
1238+
return scan_id
1239+
1240+
try:
1241+
return self.get_head_scan_for_repo(params.repo)
1242+
except APIResourceNotFound:
1243+
return None
1244+
11571245
@staticmethod
11581246
def update_package_values(pkg: Package) -> Package:
11591247
pkg.purl = f"{pkg.name}@{pkg.version}"
@@ -1242,8 +1330,8 @@ def get_added_and_removed_packages(
12421330
OVERWRITES whatever the diff embedded, before anything reads it.
12431331
Either way the embedded license payload is dead weight, and on
12441332
large dependency trees it inflated the diff response past ~2.3MB
1245-
and truncated it mid-string, crashing ``response.json()``
1246-
(CE-224, customer: Tremendous). Defaulting to ``False`` keeps the
1333+
and truncated it mid-string, crashing ``response.json()``.
1334+
Defaulting to ``False`` keeps the
12471335
diff lean with zero change to any output artifact. The parameter
12481336
is retained as an explicit override seam, not wired to the
12491337
``--exclude-license-details`` user flag (which still governs the
@@ -1388,11 +1476,9 @@ def create_new_diff(
13881476
log.info("No supported manifest files found - creating empty scan for diff comparison")
13891477
scan_files = Core.empty_head_scan_file()
13901478

1391-
try:
1392-
# Get head scan ID
1393-
head_full_scan_id = self.get_head_scan_for_repo(params.repo)
1394-
except APIResourceNotFound:
1395-
head_full_scan_id = None
1479+
# Resolve the baseline scan: --base-scan-id / --base-commit-sha override
1480+
# the repository's head scan (None only when the repo has no head scan yet).
1481+
head_full_scan_id = self.resolve_base_full_scan_id(params)
13961482

13971483
# If no head scan exists, create an empty baseline scan
13981484
if head_full_scan_id is None:
@@ -1475,7 +1561,7 @@ def create_new_diff(
14751561
# (the --exclude-license-details user flag) into the diff request. The
14761562
# diff path never consumes embedded license data (see
14771563
# get_added_and_removed_packages docstring), so requesting it only bloats
1478-
# the response and risks the CE-224 truncation crash on large repos. The
1564+
# the response and risks the truncation crash on large repos. The
14791565
# user flag still controls the dashboard report URL below; it just no
14801566
# longer gates this internal diff payload.
14811567
(

tests/core/test_sdk_methods.py

Lines changed: 132 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import pytest
22
from socketdev.fullscans import FullScanParams
33

4+
from socketsecurity.config import CliConfig
45
from socketsecurity.core import Core
56
from socketsecurity.core.socket_config import SocketConfig
67

@@ -10,6 +11,23 @@ def core(mock_sdk_with_responses):
1011
config = SocketConfig(api_key="test_key")
1112
return Core(config=config, sdk=mock_sdk_with_responses)
1213

14+
15+
def make_cli_config(*extra_args):
16+
return CliConfig.from_args(["--api-token", "test-token", "--repo", "test", *extra_args])
17+
18+
19+
def make_full_scan_params(**overrides):
20+
values = {
21+
"repo": "test",
22+
"branch": "main",
23+
"commit_hash": "head123",
24+
"scan_type": "socket",
25+
"workspace": None,
26+
}
27+
values.update(overrides)
28+
return FullScanParams(**values)
29+
30+
1331
def test_get_repo_info(core, mock_sdk_with_responses):
1432
"""Test getting repository information"""
1533
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):
4462
head_scan_id = core.get_head_scan_for_repo("no-head")
4563
assert head_scan_id is None
4664

65+
def test_get_full_scan_id_by_commit(core, mock_sdk_with_responses):
66+
"""Looks up the newest full scan for a repo + commit via the list endpoint"""
67+
mock_sdk_with_responses.fullscans.get.return_value = {
68+
"results": [{"id": "base-scan-id", "commit_hash": "abc123"}],
69+
"nextPage": None,
70+
}
71+
72+
scan_id = core.get_full_scan_id_by_commit("test", "abc123")
73+
74+
assert scan_id == "base-scan-id"
75+
mock_sdk_with_responses.fullscans.get.assert_called_once_with(
76+
core.config.org_slug,
77+
{
78+
"repo": "test",
79+
"commit_hash": "abc123",
80+
"sort": "created_at",
81+
"direction": "desc",
82+
"per_page": 1,
83+
},
84+
)
85+
86+
87+
def test_get_full_scan_id_by_commit_scopes_to_workspace_and_scan_type(core, mock_sdk_with_responses):
88+
"""Looks up a baseline scan from the same workspace and scan type as the new scan"""
89+
mock_sdk_with_responses.fullscans.get.return_value = {
90+
"results": [{"id": "workspace-reach-base", "commit_hash": "abc123"}],
91+
"nextPage": None,
92+
}
93+
94+
scan_id = core.get_full_scan_id_by_commit(
95+
"test",
96+
"abc123",
97+
workspace="customer-a",
98+
scan_type="socket_tier1",
99+
)
100+
101+
assert scan_id == "workspace-reach-base"
102+
mock_sdk_with_responses.fullscans.get.assert_called_once_with(
103+
core.config.org_slug,
104+
{
105+
"repo": "test",
106+
"commit_hash": "abc123",
107+
"sort": "created_at",
108+
"direction": "desc",
109+
"per_page": 1,
110+
"workspace": "customer-a",
111+
"scan_type": "socket_tier1",
112+
},
113+
)
114+
115+
116+
def test_get_full_scan_id_by_commit_not_found(core, mock_sdk_with_responses):
117+
"""No scan for the commit returns None (empty results and SDK error dict)"""
118+
mock_sdk_with_responses.fullscans.get.return_value = {"results": [], "nextPage": None}
119+
assert core.get_full_scan_id_by_commit("test", "abc123") is None
120+
121+
mock_sdk_with_responses.fullscans.get.return_value = {}
122+
assert core.get_full_scan_id_by_commit("test", "abc123") is None
123+
124+
def test_resolve_base_full_scan_id_defaults_to_head_scan(core):
125+
"""Without base overrides the repository head scan is the baseline"""
126+
assert core.resolve_base_full_scan_id(make_full_scan_params()) == "head"
127+
128+
def test_resolve_base_full_scan_id_uses_base_scan_id(core):
129+
"""--base-scan-id is used verbatim, without touching the repo endpoint"""
130+
core.cli_config = make_cli_config("--base-scan-id", "explicit-base")
131+
132+
assert core.resolve_base_full_scan_id(make_full_scan_params()) == "explicit-base"
133+
core.sdk.repos.repo.assert_not_called()
134+
135+
def test_resolve_base_full_scan_id_uses_base_commit_sha(core):
136+
"""--base-commit-sha resolves through the full-scans list endpoint"""
137+
core.cli_config = make_cli_config("--base-commit-sha", "abc123")
138+
core.sdk.fullscans.get.return_value = {
139+
"results": [{"id": "merge-base-scan"}],
140+
"nextPage": None,
141+
}
142+
143+
params = make_full_scan_params(workspace="customer-a", scan_type="socket_tier1")
144+
145+
assert core.resolve_base_full_scan_id(params) == "merge-base-scan"
146+
core.sdk.repos.repo.assert_not_called()
147+
core.sdk.fullscans.get.assert_called_once_with(
148+
core.config.org_slug,
149+
{
150+
"repo": "test",
151+
"commit_hash": "abc123",
152+
"sort": "created_at",
153+
"direction": "desc",
154+
"per_page": 1,
155+
"workspace": "customer-a",
156+
"scan_type": "socket_tier1",
157+
},
158+
)
159+
160+
def test_resolve_base_full_scan_id_commit_sha_not_found_exits(core):
161+
"""A --base-commit-sha with no scan is a hard error (exit_code_on_api_error)"""
162+
core.cli_config = make_cli_config("--base-commit-sha", "abc123")
163+
core.sdk.fullscans.get.return_value = {"results": [], "nextPage": None}
164+
165+
with pytest.raises(SystemExit) as exc_info:
166+
core.resolve_base_full_scan_id(make_full_scan_params())
167+
assert exc_info.value.code == core.cli_config.exit_code_on_api_error
168+
169+
def test_resolve_base_full_scan_id_commit_sha_not_found_disable_blocking(core):
170+
"""--disable-blocking keeps the missing-base error from failing the build"""
171+
core.cli_config = make_cli_config("--base-commit-sha", "abc123", "--disable-blocking")
172+
core.sdk.fullscans.get.return_value = {"results": [], "nextPage": None}
173+
174+
with pytest.raises(SystemExit) as exc_info:
175+
core.resolve_base_full_scan_id(make_full_scan_params())
176+
assert exc_info.value.code == 0
177+
47178
def test_get_full_scan(core, mock_sdk_with_responses, head_scan_metadata, head_scan_stream):
48179
"""Test getting an existing full scan"""
49180
full_scan = core.get_full_scan("head")
@@ -98,7 +229,7 @@ def test_get_added_and_removed_packages(core):
98229
# Verify SDK was called correctly.
99230
# include_license_details defaults to "false": the diff path never consumes
100231
# embedded license data (license artifacts come from the PURL endpoint), so
101-
# requesting it only bloats the response and risks the CE-224 truncation
232+
# requesting it only bloats the response and risks the truncation
102233
# crash on large repos.
103234
core.sdk.fullscans.stream_diff.assert_called_once_with(
104235
core.config.org_slug,

0 commit comments

Comments
 (0)