Skip to content

Commit 7d7ac0c

Browse files
authored
feat(reach): align reachability flags and coana env with Node CLI (#226)
Bring the Python CLI's reachability surface to parity with the Node CLI: - --reach-disable-external-tool-checks -> coana --disable-external-tool-checks - forward SOCKET_CLI_VERSION + SOCKET_CALLER_USER_AGENT to coana (proxy is left to coana, which reads/inherits HTTPS_PROXY/HTTP_PROXY itself) - omit SOCKET_REPO_NAME/SOCKET_BRANCH_NAME for the default repo/branch sentinels - Node-style --reach-analysis-timeout/--reach-analysis-memory-limit as primary names, --reach-timeout/--reach-memory-limit kept as hidden aliases - --reach-debug -> coana --debug (global --enable-debug -> -d unchanged) - retry tier1 finalize with exponential backoff (3 attempts), never raising Memory-limit and concurrency are intentionally NOT hardcoded: coana already defaults to 8192 MB and concurrency 1, so the CLI omits the flags and lets coana apply them (and still forwards an explicit value when the user sets one). Splitting stays explicitly disabled (--disable-analysis-splitting) because coana defaults it ON. Removes stray always-on WARNING logging in the reachability runner. Adds a CHANGELOG 2.4.2 entry and tests for the flags/aliases, the coana command/env builder, and finalize retry.
1 parent dcab444 commit 7d7ac0c

11 files changed

Lines changed: 382 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

3+
## 2.4.2
4+
5+
### Added: reachability flag and Coana environment alignment with the Node CLI
6+
7+
- New `--reach-disable-external-tool-checks` flag (passes `--disable-external-tool-checks`
8+
to the Coana CLI).
9+
- New `--reach-debug` flag to enable Coana debug output (`--debug`) independently of the
10+
global `--enable-debug`.
11+
- Node-style `--reach-analysis-timeout` and `--reach-analysis-memory-limit` are now the
12+
primary flag names; the previous `--reach-timeout` / `--reach-memory-limit` continue to
13+
work as hidden aliases.
14+
- The Coana subprocess now receives `SOCKET_CLI_VERSION` and `SOCKET_CALLER_USER_AGENT` so
15+
calls are attributed to the Python CLI. Proxies continue to work via the inherited
16+
`HTTPS_PROXY` / `HTTP_PROXY` environment variables, which Coana reads itself.
17+
- `SOCKET_REPO_NAME` / `SOCKET_BRANCH_NAME` are no longer forwarded to Coana when the repo
18+
and branch are the default sentinels, avoiding cross-run reachability cache-bucket
19+
collisions.
20+
- Tier 1 reachability finalize now retries with exponential backoff instead of giving up on
21+
the first transient error.
22+
323
## 2.4.1
424

525
### Added: pyenv in the Docker image

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
66

77
[project]
88
name = "socketsecurity"
9-
version = "2.4.1"
9+
version = "2.4.2"
1010
requires-python = ">= 3.11"
1111
license = {"file" = "LICENSE"}
1212
dependencies = [

socketsecurity/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
__author__ = 'socket.dev'
2-
__version__ = '2.4.1'
2+
__version__ = '2.4.2'
33
USER_AGENT = f'SocketPythonCLI/{__version__}'

socketsecurity/config.py

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ class CliConfig:
139139
reach_continue_on_install_errors: bool = False
140140
reach_continue_on_missing_lock_files: bool = False
141141
reach_continue_on_no_source_files: bool = False
142+
reach_debug: bool = False
143+
reach_disable_external_tool_checks: bool = False
142144
max_purl_batch_size: int = 5000
143145
enable_commit_status: bool = False
144146
legal: bool = False
@@ -267,6 +269,8 @@ def from_args(cls, args_list: Optional[List[str]] = None) -> 'CliConfig':
267269
'reach_continue_on_install_errors': args.reach_continue_on_install_errors,
268270
'reach_continue_on_missing_lock_files': args.reach_continue_on_missing_lock_files,
269271
'reach_continue_on_no_source_files': args.reach_continue_on_no_source_files,
272+
'reach_debug': args.reach_debug,
273+
'reach_disable_external_tool_checks': args.reach_disable_external_tool_checks,
270274
'max_purl_batch_size': args.max_purl_batch_size,
271275
'enable_commit_status': args.enable_commit_status,
272276
'legal': args.legal or args.legal_format == "fossa",
@@ -878,18 +882,32 @@ def create_argument_parser() -> argparse.ArgumentParser:
878882
help="Specific version of @coana-tech/cli to use (e.g., '1.2.3')"
879883
)
880884
reachability_group.add_argument(
881-
"--reach-timeout",
885+
"--reach-analysis-timeout",
882886
dest="reach_analysis_timeout",
883887
type=int,
884888
metavar="<seconds>",
885889
help="Timeout for reachability analysis in seconds"
886890
)
891+
# Backwards-compatible alias for the pre-alignment name. Kept working, hidden from help.
887892
reachability_group.add_argument(
888-
"--reach-memory-limit",
893+
"--reach-timeout",
894+
dest="reach_analysis_timeout",
895+
type=int,
896+
help=argparse.SUPPRESS
897+
)
898+
reachability_group.add_argument(
899+
"--reach-analysis-memory-limit",
889900
dest="reach_analysis_memory_limit",
890901
type=int,
891902
metavar="<mb>",
892-
help="Memory limit for reachability analysis in MB"
903+
help="Memory limit for reachability analysis in MB (defaults to the coana CLI's own default, currently 8192)"
904+
)
905+
# Backwards-compatible alias for the pre-alignment name. Kept working, hidden from help.
906+
reachability_group.add_argument(
907+
"--reach-memory-limit",
908+
dest="reach_analysis_memory_limit",
909+
type=int,
910+
help=argparse.SUPPRESS
893911
)
894912
reachability_group.add_argument(
895913
"--reach-ecosystems",
@@ -957,7 +975,7 @@ def create_argument_parser() -> argparse.ArgumentParser:
957975
dest="reach_concurrency",
958976
type=int,
959977
metavar="<number>",
960-
help="Concurrency level for reachability analysis (must be >= 1)"
978+
help="Concurrency level for reachability analysis (must be >= 1; defaults to the coana CLI's own default, currently 1)"
961979
)
962980
reachability_group.add_argument(
963981
"--reach-additional-params",
@@ -1002,6 +1020,20 @@ def create_argument_parser() -> argparse.ArgumentParser:
10021020
action="store_true",
10031021
help=argparse.SUPPRESS
10041022
)
1023+
reachability_group.add_argument(
1024+
"--reach-debug",
1025+
dest="reach_debug",
1026+
action="store_true",
1027+
help="Enable debug output for the reachability analysis (passes --debug to the coana CLI). "
1028+
"Independent of the global --enable-debug flag."
1029+
)
1030+
reachability_group.add_argument(
1031+
"--reach-disable-external-tool-checks",
1032+
dest="reach_disable_external_tool_checks",
1033+
action="store_true",
1034+
help="Disable coana's external tool availability checks during reachability analysis "
1035+
"(passes --disable-external-tool-checks to the coana CLI)."
1036+
)
10051037

10061038
parser.add_argument(
10071039
'--version',

socketsecurity/core/__init__.py

Lines changed: 40 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@
7171
# Stream the facts file in 1 MiB chunks so large files aren't held fully in memory.
7272
SOCKET_FACTS_BROTLI_CHUNK_SIZE = 1024 * 1024
7373

74+
# Tier 1 reachability finalize retry policy. The finalize call links the tier1 scan to the
75+
# full scan and can fail transiently (network/API blips); a few backoff retries make it robust.
76+
TIER1_FINALIZE_MAX_ATTEMPTS = 3
77+
TIER1_FINALIZE_BACKOFF_SECONDS = 1.0
78+
7479

7580
def _humanize_alert_type(alert_type: str) -> str:
7681
"""Convert a camelCase/PascalCase alert type into a Title-Cased label.
@@ -549,20 +554,43 @@ def finalize_tier1_scan(self, full_scan_id: str, facts_file_path: str) -> bool:
549554
log.debug(f"Failed to read tier1ReachabilityScanId from {facts_file_path}: {e}")
550555
return False
551556

552-
# Call the SDK to finalize the tier 1 scan
553-
try:
554-
success = self.sdk.fullscans.finalize_tier1(
555-
full_scan_id=full_scan_id,
556-
tier1_reachability_scan_id=tier1_scan_id,
557-
)
557+
# Call the SDK to finalize the tier 1 scan, retrying transient failures with backoff.
558+
last_error: Optional[Exception] = None
559+
for attempt in range(1, TIER1_FINALIZE_MAX_ATTEMPTS + 1):
560+
try:
561+
success = self.sdk.fullscans.finalize_tier1(
562+
full_scan_id=full_scan_id,
563+
tier1_reachability_scan_id=tier1_scan_id,
564+
)
558565

559-
if success:
560-
log.debug(f"Successfully finalized tier 1 scan {tier1_scan_id} for full scan {full_scan_id}")
561-
return success
566+
if success:
567+
log.debug(f"Successfully finalized tier 1 scan {tier1_scan_id} for full scan {full_scan_id}")
568+
return True
562569

563-
except Exception as e:
564-
log.debug(f"Unable to finalize tier 1 scan: {e}")
565-
return False
570+
log.debug(
571+
f"finalize_tier1 returned a falsy result for scan {tier1_scan_id} "
572+
f"(attempt {attempt}/{TIER1_FINALIZE_MAX_ATTEMPTS})"
573+
)
574+
except Exception as e:
575+
last_error = e
576+
log.debug(
577+
f"Unable to finalize tier 1 scan (attempt {attempt}/{TIER1_FINALIZE_MAX_ATTEMPTS}): {e}"
578+
)
579+
580+
if attempt < TIER1_FINALIZE_MAX_ATTEMPTS:
581+
time.sleep(TIER1_FINALIZE_BACKOFF_SECONDS * (2 ** (attempt - 1)))
582+
583+
if last_error is not None:
584+
log.debug(
585+
f"Giving up finalizing tier 1 scan {tier1_scan_id} after "
586+
f"{TIER1_FINALIZE_MAX_ATTEMPTS} attempts: {last_error}"
587+
)
588+
else:
589+
log.debug(
590+
f"Giving up finalizing tier 1 scan {tier1_scan_id} after "
591+
f"{TIER1_FINALIZE_MAX_ATTEMPTS} attempts"
592+
)
593+
return False
566594

567595
@staticmethod
568596
def _compress_facts_file(source_path: str) -> str:

socketsecurity/core/tools/reachability.py

Lines changed: 40 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,31 @@
11
from socketdev import socketdev
22
from typing import List, Optional, Dict, Any
33
import os
4+
import platform
45
import subprocess
56
import json
67
import pathlib
78
import logging
89
import sys
910

11+
from socketsecurity import __version__
12+
1013
log = logging.getLogger(__name__)
1114

1215

16+
def _build_caller_user_agent() -> str:
17+
"""Build the SOCKET_CALLER_USER_AGENT string forwarded to the coana CLI.
18+
19+
Mirrors the Node CLI's ``<product>/<version> <runtime>/<version> <platform>/<arch>``
20+
shape so the backend can attribute reachability calls to the Python CLI.
21+
"""
22+
return (
23+
f"socket/{__version__} "
24+
f"python/{platform.python_version()} "
25+
f"{platform.system().lower()}/{platform.machine().lower()}"
26+
)
27+
28+
1329
class ReachabilityAnalyzer:
1430
def __init__(self, sdk: socketdev, api_token: str):
1531
self.sdk = sdk
@@ -108,6 +124,8 @@ def run_reachability_analysis(
108124
continue_on_install_errors: bool = False,
109125
continue_on_missing_lock_files: bool = False,
110126
continue_on_no_source_files: bool = False,
127+
reach_debug: bool = False,
128+
disable_external_tool_checks: bool = False,
111129
) -> Dict[str, Any]:
112130
"""
113131
Run reachability analysis.
@@ -147,8 +165,7 @@ def run_reachability_analysis(
147165

148166
# Add required arguments
149167
output_dir = str(pathlib.Path(output_path).parent)
150-
log.warning(f"output_dir: {output_dir}")
151-
log.warning(f"output_path: {output_path}")
168+
log.debug(f"output_dir: {output_dir}, output_path: {output_path}")
152169
cmd.extend([
153170
"--output-dir", output_dir,
154171
"--socket-mode", output_path,
@@ -197,6 +214,12 @@ def run_reachability_analysis(
197214
if enable_debug:
198215
cmd.append("-d")
199216

217+
if reach_debug:
218+
cmd.append("--debug")
219+
220+
if disable_external_tool_checks:
221+
cmd.append("--disable-external-tool-checks")
222+
200223
if use_only_pregenerated_sboms:
201224
cmd.append("--use-only-pregenerated-sboms")
202225

@@ -222,14 +245,25 @@ def run_reachability_analysis(
222245
# Required environment variables for Coana CLI
223246
env["SOCKET_ORG_SLUG"] = org_slug
224247
env["SOCKET_CLI_API_TOKEN"] = self.api_token
225-
226-
# Optional environment variables
248+
249+
# Identify the calling CLI to the coana tool / backend (parity with the Node CLI).
250+
env["SOCKET_CLI_VERSION"] = __version__
251+
env["SOCKET_CALLER_USER_AGENT"] = _build_caller_user_agent()
252+
253+
# NOTE: no proxy env is set here. coana already reads HTTPS_PROXY/HTTP_PROXY itself, and
254+
# we pass the full parent env above, so it inherits them. A SOCKET_CLI_API_PROXY override
255+
# should only be set from an explicit --proxy flag (not yet implemented), since seeding it
256+
# from HTTPS_PROXY would be a no-op (it's the same value coana already resolves).
257+
258+
# Optional environment variables.
259+
# NOTE: repo/branch are intentionally omitted by the caller (passed as None) when they
260+
# are the default sentinels, to avoid polluting coana's per-repo/branch cache buckets.
227261
if repo_name:
228262
env["SOCKET_REPO_NAME"] = repo_name
229-
263+
230264
if branch_name:
231265
env["SOCKET_BRANCH_NAME"] = branch_name
232-
266+
233267
# Set NODE_TLS_REJECT_UNAUTHORIZED=0 if allow_unverified is True
234268
if allow_unverified:
235269
env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"

socketsecurity/socketcli.py

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,12 @@ def _write_attribution_file(config, payload: dict) -> None:
9595

9696
DEFAULT_API_TIMEOUT = 1200
9797

98+
# Sentinel repo/branch names used when none can be detected from git or supplied via flags.
99+
# When the repo/branch are these defaults we skip forwarding SOCKET_REPO_NAME/SOCKET_BRANCH_NAME
100+
# to the coana CLI so unrelated default-named runs don't share reachability cache buckets.
101+
DEFAULT_REPO_NAME = "socket-default-repo"
102+
DEFAULT_BRANCH_NAME = "socket-default-branch"
103+
98104

99105
def get_api_request_timeout(config: CliConfig) -> int:
100106
return config.timeout if config.timeout is not None else DEFAULT_API_TIMEOUT
@@ -288,16 +294,21 @@ def main_code():
288294
except NoSuchPathError:
289295
raise Exception(f"Unable to find path {config.target_path}")
290296

297+
# Track whether repo/branch fell back to the default sentinels so reachability can skip
298+
# forwarding them as coana cache-bucket keys (computed before any workspace suffixing).
299+
repo_defaulted = not config.repo
300+
branch_defaulted = not config.branch
301+
291302
if not config.repo:
292-
base_repo_name = "socket-default-repo"
303+
base_repo_name = DEFAULT_REPO_NAME
293304
if config.workspace_name:
294305
config.repo = f"{base_repo_name}-{config.workspace_name}"
295306
else:
296307
config.repo = base_repo_name
297308
log.debug(f"Using default repository name: {config.repo}")
298-
309+
299310
if not config.branch:
300-
config.branch = "socket-default-branch"
311+
config.branch = DEFAULT_BRANCH_NAME
301312
log.debug(f"Using default branch name: {config.branch}")
302313

303314
# Calculate the scan paths - combine target_path with sub_paths if provided
@@ -384,8 +395,8 @@ def main_code():
384395
enable_analysis_splitting=config.reach_enable_analysis_splitting or False,
385396
detailed_analysis_log_file=config.reach_detailed_analysis_log_file or False,
386397
lazy_mode=config.reach_lazy_mode or False,
387-
repo_name=config.repo,
388-
branch_name=config.branch,
398+
repo_name=None if repo_defaulted else config.repo,
399+
branch_name=None if branch_defaulted else config.branch,
389400
version=config.reach_version,
390401
concurrency=config.reach_concurrency,
391402
additional_params=config.reach_additional_params,
@@ -396,6 +407,8 @@ def main_code():
396407
continue_on_install_errors=config.reach_continue_on_install_errors,
397408
continue_on_missing_lock_files=config.reach_continue_on_missing_lock_files,
398409
continue_on_no_source_files=config.reach_continue_on_no_source_files,
410+
reach_debug=config.reach_debug,
411+
disable_external_tool_checks=config.reach_disable_external_tool_checks,
399412
)
400413

401414
log.info("Reachability analysis completed successfully")

tests/unit/test_config.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,55 @@ def test_config_file_json_sets_defaults(self, tmp_path):
166166
assert config.sarif_reachability == "reachable"
167167

168168

169+
class TestReachAlignmentFlags:
170+
"""Tests for the reachability flag/default alignment with the Node CLI."""
171+
172+
BASE_ARGS = ["--api-token", "test-token", "--repo", "test-repo"]
173+
174+
def test_reach_defaults_are_unset_and_delegated_to_coana(self):
175+
"""memory-limit/concurrency/timeout are not hardcoded; omitted so coana applies its
176+
own defaults (8192 MB / concurrency 1 / 600s), which already match what we'd set."""
177+
config = CliConfig.from_args(self.BASE_ARGS + ["--reach"])
178+
assert config.reach_analysis_memory_limit is None
179+
assert config.reach_concurrency is None
180+
assert config.reach_analysis_timeout is None
181+
182+
def test_reach_node_style_name_aliases(self):
183+
"""G8: Node-style primary names map to the same dests."""
184+
config = CliConfig.from_args(
185+
self.BASE_ARGS
186+
+ ["--reach", "--reach-analysis-timeout", "300", "--reach-analysis-memory-limit", "2048"]
187+
)
188+
assert config.reach_analysis_timeout == 300
189+
assert config.reach_analysis_memory_limit == 2048
190+
191+
def test_reach_legacy_name_aliases_still_work(self):
192+
"""G8: pre-alignment names keep working (hidden aliases)."""
193+
config = CliConfig.from_args(
194+
self.BASE_ARGS + ["--reach", "--reach-timeout", "111", "--reach-memory-limit", "512"]
195+
)
196+
assert config.reach_analysis_timeout == 111
197+
assert config.reach_analysis_memory_limit == 512
198+
199+
def test_reach_debug_flag(self):
200+
"""G9: dedicated --reach-debug flag, independent of --enable-debug."""
201+
config = CliConfig.from_args(self.BASE_ARGS + ["--reach", "--reach-debug"])
202+
assert config.reach_debug is True
203+
assert config.enable_debug is False
204+
205+
def test_reach_disable_external_tool_checks_flag(self):
206+
"""G1: --reach-disable-external-tool-checks parses to its dest."""
207+
config = CliConfig.from_args(
208+
self.BASE_ARGS + ["--reach", "--reach-disable-external-tool-checks"]
209+
)
210+
assert config.reach_disable_external_tool_checks is True
211+
212+
def test_reach_new_flags_default_false(self):
213+
config = CliConfig.from_args(self.BASE_ARGS + ["--reach"])
214+
assert config.reach_debug is False
215+
assert config.reach_disable_external_tool_checks is False
216+
217+
169218
def test_pyproject_requires_python_matches_tomllib_usage():
170219
pyproject = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))
171220
requires_python = pyproject["project"]["requires-python"]

0 commit comments

Comments
 (0)