77import pathlib
88import logging
99import sys
10+ import tempfile
1011
1112from 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