Skip to content

Commit 38d3a97

Browse files
committed
Disable npx caching and add npm-install + node fallback for coana
Mirror the Socket Node CLI's coana launcher: - Run the engine via `npx --yes --force` so the npx cache is bypassed; a corrupt or partial cache entry can no longer wedge a reachability run. - Fall back to `npm install --no-save --prefix <tmp> @coana-tech/cli@<ver>` + `node <bin>` when the npx launcher is missing or dies before coana starts (spawn error / signal / exit >= 128). Small positive exit codes are treated as real coana failures and are not retried. - Toggle with SOCKET_CLI_COANA_FORCE_NPM_INSTALL and SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK. - Strip npm_package_* env vars before spawning coana to avoid E2BIG in large monorepos. Kept on version 2.4.7 (same unreleased version as the pin change).
1 parent adb2de7 commit 38d3a97

4 files changed

Lines changed: 353 additions & 82 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
untouched (never auto-updated or downgraded).
1313
- Opt into always-newest with `--reach-version latest`; pin an explicit version with
1414
`--reach-version <semver>` (unchanged).
15+
- npx caching is now disabled (`npx --yes --force`), matching the Socket Node CLI, so a
16+
corrupt/partial npx cache entry can't wedge a run.
17+
- Added an `npm install` + `node` fallback for when the `npx` launcher is missing or fails
18+
before the engine starts. Tunable via `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` (use the
19+
fallback as the primary path) and `SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK` (never fall back).
20+
Also strips `npm_package_*` env vars before spawning the engine to avoid `E2BIG` in large
21+
monorepos.
1522

1623
## 2.4.6
1724

docs/cli-reference.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -313,7 +313,11 @@ Sample config files:
313313
314314
For CI-specific examples and guidance, see [`ci-cd.md`](ci-cd.md).
315315
316-
The CLI runs a pinned `@coana-tech/cli` version via `npx` (fetched on first use, then cached); it does **not** auto-update the engine or install it globally. Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan.
316+
The CLI runs a pinned `@coana-tech/cli` version via `npx --yes --force`; it does **not** auto-update the engine or install it globally. `--force` disables the npx cache (matching the Socket Node CLI) so a corrupt or partial cache entry can't wedge a run. If the `npx` launcher is unavailable or fails before the engine starts, the CLI falls back to `npm install`-ing the pinned version into a temp directory and running it via `node`. Pass `--reach-version latest` to opt into the newest published version. Use `--reach` to enable reachability analysis during a full scan, or add `--only-facts-file` (with `--reach`) to submit only the reachability facts file (`.socket.facts.json`) when creating the full scan.
317+
318+
The launcher fallback can be tuned via environment variables:
319+
- `SOCKET_CLI_COANA_FORCE_NPM_INSTALL` — skip `npx` entirely and always use the `npm install` + `node` path (useful where `npx` is known-broken).
320+
- `SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK` — never fall back; surface the `npx` failure directly.
317321

318322
#### Advanced Configuration
319323
| Parameter | Required | Default | Description |

socketsecurity/core/tools/reachability.py

Lines changed: 184 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import pathlib
88
import logging
99
import sys
10+
import tempfile
1011

1112
from socketsecurity import __version__
1213

@@ -55,8 +56,11 @@ def _resolve_coana_package_spec(self, version: Optional[str] = None) -> str:
5556
Returns:
5657
str: The package specifier to use with npx (e.g. '@coana-tech/cli@15.3.22').
5758
"""
58-
effective = (version or DEFAULT_COANA_CLI_VERSION).strip()
59-
return f"@coana-tech/cli@{effective}"
59+
return f"@coana-tech/cli@{self._resolve_coana_version(version)}"
60+
61+
def _resolve_coana_version(self, version: Optional[str] = None) -> str:
62+
"""Resolve the effective @coana-tech/cli version string (see _resolve_coana_package_spec)."""
63+
return (version or DEFAULT_COANA_CLI_VERSION).strip()
6064

6165

6266
def run_reachability_analysis(
@@ -122,87 +126,85 @@ def run_reachability_analysis(
122126
Returns:
123127
Dict containing scan_id and report_path
124128
"""
125-
# Resolve the pinned (or explicitly requested) @coana-tech/cli version for npx
126-
cli_package = self._resolve_coana_package_spec(version)
127-
128-
# Build CLI command arguments
129-
cmd = ["npx", cli_package, "run", "."]
130-
129+
# Build the coana CLI arguments (everything after the package spec). The launcher
130+
# (npx, or the npm-install + node fallback) is chosen in _spawn_coana() below.
131+
coana_args = ["run", "."]
132+
131133
# Add required arguments
132134
output_dir = str(pathlib.Path(output_path).parent)
133135
log.debug(f"output_dir: {output_dir}, output_path: {output_path}")
134-
cmd.extend([
136+
coana_args.extend([
135137
"--output-dir", output_dir,
136138
"--socket-mode", output_path,
137139
"--disable-report-submission"
138140
])
139141

140142
# Add conditional arguments
141143
if timeout:
142-
cmd.extend(["--analysis-timeout", str(timeout)])
144+
coana_args.extend(["--analysis-timeout", str(timeout)])
143145

144146
if memory_limit:
145-
cmd.extend(["--memory-limit", str(memory_limit)])
147+
coana_args.extend(["--memory-limit", str(memory_limit)])
146148

147149
if disable_analytics:
148-
cmd.append("--disable-analytics-sharing")
150+
coana_args.append("--disable-analytics-sharing")
149151

150152
# Analysis splitting is disabled by default; only omit the flag if explicitly enabled
151153
if not enable_analysis_splitting:
152-
cmd.append("--disable-analysis-splitting")
154+
coana_args.append("--disable-analysis-splitting")
153155

154156
if detailed_analysis_log_file:
155-
cmd.append("--print-analysis-log-file")
157+
coana_args.append("--print-analysis-log-file")
156158

157159
if lazy_mode:
158-
cmd.append("--lazy-mode")
160+
coana_args.append("--lazy-mode")
159161

160162
# KEY POINT: Only add manifest tar hash if we have one
161163
if tar_hash:
162-
cmd.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash])
164+
coana_args.extend(["--run-without-docker", "--manifests-tar-hash", tar_hash])
163165

164166
if ecosystems:
165-
cmd.extend(["--purl-types"] + ecosystems)
167+
coana_args.extend(["--purl-types"] + ecosystems)
166168

167169
if exclude_paths:
168-
cmd.extend(["--exclude-dirs"] + exclude_paths)
170+
coana_args.extend(["--exclude-dirs"] + exclude_paths)
169171

170172
if min_severity:
171-
cmd.extend(["--min-severity", min_severity])
173+
coana_args.extend(["--min-severity", min_severity])
172174

173175
if skip_cache:
174-
cmd.append("--skip-cache-usage")
176+
coana_args.append("--skip-cache-usage")
175177

176178
if concurrency:
177-
cmd.extend(["--concurrency", str(concurrency)])
179+
coana_args.extend(["--concurrency", str(concurrency)])
178180

179181
if enable_debug:
180-
cmd.append("-d")
182+
coana_args.append("-d")
181183

182184
if reach_debug:
183-
cmd.append("--debug")
185+
coana_args.append("--debug")
184186

185187
if disable_external_tool_checks:
186-
cmd.append("--disable-external-tool-checks")
188+
coana_args.append("--disable-external-tool-checks")
187189

188190
if use_only_pregenerated_sboms:
189-
cmd.append("--use-only-pregenerated-sboms")
191+
coana_args.append("--use-only-pregenerated-sboms")
190192

191193
if continue_on_analysis_errors:
192-
cmd.append("--reach-continue-on-analysis-errors")
194+
coana_args.append("--reach-continue-on-analysis-errors")
193195

194196
if continue_on_install_errors:
195-
cmd.append("--reach-continue-on-install-errors")
197+
coana_args.append("--reach-continue-on-install-errors")
196198

197199
if continue_on_missing_lock_files:
198-
cmd.append("--reach-continue-on-missing-lock-files")
200+
coana_args.append("--reach-continue-on-missing-lock-files")
199201

200202
if continue_on_no_source_files:
201-
cmd.append("--reach-continue-on-no-source-files")
203+
coana_args.append("--reach-continue-on-no-source-files")
202204

203205
# Add any additional parameters provided by the user
204206
if additional_params:
205-
cmd.extend(additional_params)
207+
coana_args.extend(additional_params)
206208

207209
# Set up environment variables
208210
env = os.environ.copy()
@@ -233,24 +235,18 @@ def run_reachability_analysis(
233235
if allow_unverified:
234236
env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"
235237

236-
# Execute CLI
238+
# Execute coana
237239
log.info("Running reachability analysis...")
238-
log.debug(f"Reachability command: {' '.join(cmd)}")
239240
log.debug(f"Environment: SOCKET_ORG_SLUG={org_slug}, SOCKET_REPO_NAME={repo_name or 'not set'}, SOCKET_BRANCH_NAME={branch_name or 'not set'}")
240-
241+
241242
try:
242-
# Run with output streaming to stderr (don't capture output)
243-
result = subprocess.run(
244-
cmd,
245-
env=env,
246-
cwd=target_directory,
247-
stdout=sys.stderr, # Send stdout to stderr so user sees it
248-
stderr=sys.stderr, # Send stderr to stderr
249-
)
250-
251-
if result.returncode != 0:
252-
log.error(f"Reachability analysis failed with exit code {result.returncode}")
253-
raise Exception(f"Reachability analysis failed with exit code {result.returncode}")
243+
# Prefer npx (with caching disabled); fall back to `npm install` + `node`
244+
# if the npx launcher fails before coana starts (parity with the Node CLI).
245+
returncode = self._spawn_coana(coana_args, version, env, target_directory)
246+
247+
if returncode != 0:
248+
log.error(f"Reachability analysis failed with exit code {returncode}")
249+
raise Exception(f"Reachability analysis failed with exit code {returncode}")
254250

255251
# Extract scan ID from output file
256252
scan_id = self._extract_scan_id(output_path)
@@ -268,7 +264,149 @@ def run_reachability_analysis(
268264
except Exception as e:
269265
log.error(f"Failed to run reachability analysis: {str(e)}")
270266
raise Exception(f"Failed to run reachability analysis: {str(e)}")
271-
267+
268+
@staticmethod
269+
def _sanitize_coana_env(env: Dict[str, str]) -> Dict[str, str]:
270+
"""Drop npm-injected ``npm_package_*`` vars before spawning coana.
271+
272+
npm/pnpm/yarn populate one env var per leaf of the cwd's package.json
273+
(``npm_package_dependencies_*`` etc.). In large monorepos this can be tens of KB
274+
and push argv+env past the OS ARG_MAX, making the spawn fail with E2BIG before
275+
coana even starts. coana doesn't read these, so dropping them is safe; we keep
276+
``npm_config_*`` (registry/cache/proxy) untouched. Mirrors the Node CLI.
277+
"""
278+
return {k: v for k, v in env.items() if not k.startswith("npm_package_")}
279+
280+
@staticmethod
281+
def _npx_launcher_failed_before_coana(returncode: int) -> bool:
282+
"""Heuristic: did npx fail *before* coana started (so retrying is worthwhile)?
283+
284+
We stream coana's output (no capture), so we classify by exit code alone, like the
285+
Node CLI does with inherited stdio: signal kills (negative codes) and codes >= 128
286+
are conventionally launcher/signal failures -> retry. Small positive codes (1..127)
287+
are ambiguous (coana's own exit codes are small ints), so we do NOT retry.
288+
"""
289+
return returncode < 0 or returncode >= 128
290+
291+
def _spawn_coana(
292+
self,
293+
coana_args: List[str],
294+
version: Optional[str],
295+
env: Dict[str, str],
296+
cwd: str,
297+
) -> int:
298+
"""Run coana for the given args, returning the process exit code.
299+
300+
Primary path: ``npx --yes --force @coana-tech/cli@<version> ...``. ``--force``
301+
bypasses the npx cache so a corrupt/partial cache entry can't wedge the run, and
302+
``--yes`` auto-confirms the install prompt (parity with the Node CLI's dlx path,
303+
which passes ``--force`` for npm/npx and ``npm_config_dlx_cache_max_age=0`` for pnpm).
304+
305+
Fallback path: if npx is missing, or its launcher dies before coana starts, install
306+
@coana-tech/cli into a temp dir via ``npm install`` and run it directly via ``node``.
307+
Toggle with ``SOCKET_CLI_COANA_FORCE_NPM_INSTALL`` (use the fallback as the primary
308+
path) and ``SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK`` (never fall back).
309+
"""
310+
effective_version = self._resolve_coana_version(version)
311+
coana_env = self._sanitize_coana_env(env)
312+
disable_fallback = bool(os.environ.get("SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK"))
313+
314+
if os.environ.get("SOCKET_CLI_COANA_FORCE_NPM_INSTALL"):
315+
return self._spawn_coana_via_npm_install(coana_args, effective_version, coana_env, cwd)
316+
317+
package_spec = f"@coana-tech/cli@{effective_version}"
318+
# --yes auto-confirms the install prompt; --force bypasses the npx cache.
319+
npx_cmd = ["npx", "--yes", "--force", package_spec, *coana_args]
320+
log.debug(f"Reachability command: {' '.join(npx_cmd)}")
321+
try:
322+
result = subprocess.run(
323+
npx_cmd,
324+
env=coana_env,
325+
cwd=cwd,
326+
stdout=sys.stderr, # Send stdout to stderr so the user sees it
327+
stderr=sys.stderr,
328+
)
329+
except FileNotFoundError:
330+
# npx is not on PATH: the launcher provably never started coana.
331+
if disable_fallback:
332+
raise
333+
log.warning("npx not found on PATH; retrying reachability analysis via `npm install` + `node`.")
334+
return self._spawn_coana_via_npm_install(coana_args, effective_version, coana_env, cwd)
335+
336+
if result.returncode == 0:
337+
return 0
338+
339+
if not disable_fallback and self._npx_launcher_failed_before_coana(result.returncode):
340+
log.warning(
341+
f"npx launcher failed (exit {result.returncode}) before coana started; "
342+
"retrying reachability analysis via `npm install` + `node`."
343+
)
344+
return self._spawn_coana_via_npm_install(coana_args, effective_version, coana_env, cwd)
345+
346+
return result.returncode
347+
348+
def _spawn_coana_via_npm_install(
349+
self,
350+
coana_args: List[str],
351+
version: str,
352+
env: Dict[str, str],
353+
cwd: str,
354+
) -> int:
355+
"""Fallback launcher: ``npm install`` @coana-tech/cli into a temp dir, run via ``node``.
356+
357+
Used when npx is unavailable or its launcher fails before coana boots. Mirrors the
358+
Node CLI's npm-install fallback. Returns coana's exit code; raises if the install
359+
itself fails.
360+
"""
361+
install_dir = tempfile.mkdtemp(prefix="socket-coana-")
362+
npm_cmd = [
363+
"npm", "install",
364+
"--no-save", "--no-package-lock", "--no-audit", "--no-fund",
365+
"--prefix", install_dir,
366+
f"@coana-tech/cli@{version}",
367+
]
368+
log.info("Installing reachability analysis engine via npm fallback...")
369+
log.debug(f"npm install fallback command: {' '.join(npm_cmd)}")
370+
install = subprocess.run(npm_cmd, env=env, stdout=sys.stderr, stderr=sys.stderr)
371+
if install.returncode != 0:
372+
raise Exception(
373+
f"npm install fallback for @coana-tech/cli@{version} failed with exit code {install.returncode}"
374+
)
375+
376+
script_path = self._resolve_coana_bin(install_dir)
377+
node_cmd = self._build_coana_node_cmd(script_path, coana_args)
378+
log.debug(f"Reachability fallback command: {' '.join(node_cmd)}")
379+
result = subprocess.run(node_cmd, env=env, cwd=cwd, stdout=sys.stderr, stderr=sys.stderr)
380+
return result.returncode
381+
382+
@staticmethod
383+
def _resolve_coana_bin(install_dir: str) -> str:
384+
"""Resolve @coana-tech/cli's executable JS from its installed package.json ``bin`` field."""
385+
package_json_path = os.path.join(
386+
install_dir, "node_modules", "@coana-tech", "cli", "package.json"
387+
)
388+
with open(package_json_path, "r") as f:
389+
pkg = json.load(f)
390+
bin_field = pkg.get("bin")
391+
relative_bin = None
392+
if isinstance(bin_field, str):
393+
relative_bin = bin_field
394+
elif isinstance(bin_field, dict):
395+
# Prefer an entry named "coana"; otherwise take the first.
396+
relative_bin = bin_field.get("coana") or next(iter(bin_field.values()), None)
397+
if not relative_bin:
398+
raise Exception(
399+
f"@coana-tech/cli package.json at {package_json_path} is missing a usable bin entry"
400+
)
401+
return os.path.abspath(os.path.join(os.path.dirname(package_json_path), relative_bin))
402+
403+
@staticmethod
404+
def _build_coana_node_cmd(script_path: str, coana_args: List[str]) -> List[str]:
405+
"""Run a .js/.mjs entry via ``node``; invoke a native binary directly (Node CLI parity)."""
406+
if script_path.endswith(".js") or script_path.endswith(".mjs"):
407+
return ["node", script_path, *coana_args]
408+
return [script_path, *coana_args]
409+
272410
def _extract_scan_id(self, facts_file_path: str) -> Optional[str]:
273411
"""
274412
Extract tier1ReachabilityScanId from the socket facts JSON file.

0 commit comments

Comments
 (0)