narvy-cli 1.1.2__tar.gz → 1.1.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/CHANGELOG.md +30 -0
  2. {narvy_cli-1.1.2/narvy_cli.egg-info → narvy_cli-1.1.3}/PKG-INFO +1 -1
  3. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/__init__.py +1 -1
  4. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/apk_memory_preflight.py +78 -16
  5. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/decompiler.py +10 -0
  6. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/main.py +3 -0
  7. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/semgrep_engine.py +68 -20
  8. {narvy_cli-1.1.2 → narvy_cli-1.1.3/narvy_cli.egg-info}/PKG-INFO +1 -1
  9. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/LICENSE +0 -0
  10. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/MANIFEST.in +0 -0
  11. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/README.md +0 -0
  12. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/SECURITY.md +0 -0
  13. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/__main__.py +0 -0
  14. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android/__init__.py +0 -0
  15. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android/source_analyzer.py +0 -0
  16. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android/split_bundle.py +0 -0
  17. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android_rule_context.py +0 -0
  18. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/auth.py +0 -0
  19. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ci_templates/bitbucket.yml +0 -0
  20. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ci_templates/github.yml +0 -0
  21. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ci_templates/gitlab.yml +0 -0
  22. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/cloud/__init__.py +0 -0
  23. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/cloud/aws_scan.py +0 -0
  24. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/comment_filter.py +0 -0
  25. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/crypto_taint_lite.py +0 -0
  26. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/doctor.py +0 -0
  27. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/__init__.py +0 -0
  28. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/audit.py +0 -0
  29. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/host_knowledge.py +0 -0
  30. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/lynis_bootstrap.py +0 -0
  31. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/lynis_parser.py +0 -0
  32. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/narvy_checks.py +0 -0
  33. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/report.py +0 -0
  34. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/ssh_exec.py +0 -0
  35. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/__init__.py +0 -0
  36. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/binary_analyzer.py +0 -0
  37. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/plist_checks.py +0 -0
  38. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/source_analyzer.py +0 -0
  39. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/third_party_filter.py +0 -0
  40. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/trust_all_context.py +0 -0
  41. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/native_hardening.py +0 -0
  42. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/own_reports.py +0 -0
  43. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/pathhint.py +0 -0
  44. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/proc.py +0 -0
  45. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/reporter.py +0 -0
  46. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rule_engine.py +0 -0
  47. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/config.yml +0 -0
  48. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/crypto.yml +0 -0
  49. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/secrets.yml +0 -0
  50. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/storage.yml +0 -0
  51. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/webview.yml +0 -0
  52. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android.yml +0 -0
  53. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/crypto.yml +0 -0
  54. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/network.yml +0 -0
  55. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/secrets.yml +0 -0
  56. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/storage.yml +0 -0
  57. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/webview.yml +0 -0
  58. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios_swift.yml +0 -0
  59. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/go.yml +0 -0
  60. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/java.yml +0 -0
  61. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/javascript.yml +0 -0
  62. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/kotlin.yml +0 -0
  63. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/php.yml +0 -0
  64. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/python.yml +0 -0
  65. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/ruby.yml +0 -0
  66. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/rust.yml +0 -0
  67. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/secrets.yml +0 -0
  68. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/secrets_supplement.yml +0 -0
  69. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/__init__.py +0 -0
  70. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/android_deps.py +0 -0
  71. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/ios_deps.py +0 -0
  72. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/osv_client.py +0 -0
  73. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/web_deps.py +0 -0
  74. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/scope_config.py +0 -0
  75. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/stack_protector_evidence.py +0 -0
  76. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/telemetry.py +0 -0
  77. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/third_party_filter.py +0 -0
  78. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/uploader.py +0 -0
  79. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/weak_prng_context.py +0 -0
  80. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/__init__.py +0 -0
  81. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/nuclei_binary.py +0 -0
  82. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/scan_blocklist.py +0 -0
  83. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/scanner.py +0 -0
  84. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/source_analyzer.py +0 -0
  85. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/ssrf_guard.py +0 -0
  86. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/SOURCES.txt +0 -0
  87. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/dependency_links.txt +0 -0
  88. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/entry_points.txt +0 -0
  89. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/requires.txt +0 -0
  90. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/top_level.txt +0 -0
  91. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/pyproject.toml +0 -0
  92. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/setup.cfg +0 -0
  93. {narvy_cli-1.1.2 → narvy_cli-1.1.3}/setup.py +0 -0
@@ -5,6 +5,36 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.3]
9
+
10
+ ### Fixed
11
+
12
+ - APK scans were refused before decompiling on machines with less free
13
+ memory than `--max-mem` needs (about 1.3x the heap plus 1 GB), even for
14
+ small apps. On an 8 GB laptop at the default `--max-mem 4g` that was
15
+ nearly every app, and on macOS the free-memory reading was low by
16
+ several GB. `--max-mem` is a ceiling: when it does not fit but a smaller
17
+ heap still covers the app's estimated need by 1.5x, jadx now runs with
18
+ that smaller heap and the scan says so. A machine that cannot hold even
19
+ that is still refused, with the same advice as before.
20
+ - macOS: available memory comes from the kernel's own figure
21
+ (`kern.memorystatus_level`, what `memory_pressure` reports), which counts
22
+ memory the compressor can reclaim. The `vm_stat` page count is the
23
+ fallback and now includes speculative pages.
24
+ - APK scans: a structural (Semgrep) pass that failed was reported as
25
+ complete with 0 findings when semgrep exited with an error but still
26
+ printed JSON. It is now reported as incomplete with the error, after one
27
+ retry with a single worker. A run whose path filters matched none of the
28
+ app's own source files is reported the same way instead of as a clean
29
+ pass. Rule ids no longer depend on where the package is installed.
30
+
31
+ ### Changed
32
+
33
+ - Platform CI: the check removes the user Scripts/bin directory from PATH
34
+ before running scans (hosted runners ship with it on PATH, a new user's
35
+ shell does not), and prints the APK scan output on success so the
36
+ structural-pass status is in the log.
37
+
8
38
  ## [1.1.2]
9
39
 
10
40
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Free, local SAST scanner for Android, iOS, and polyglot web/backend source - plus passive web, host-audit, and cloud-posture scanning.
5
5
  Author: Narvy
6
6
  License: Apache-2.0
@@ -1,3 +1,3 @@
1
1
  """Narvy CLI - free, local SAST for Android, iOS and polyglot source."""
2
2
 
3
- __version__ = "1.1.2"
3
+ __version__ = "1.1.3"
@@ -24,6 +24,13 @@ JVM_RSS_OVERHEAD = 1.30
24
24
 
25
25
  RAM_SAFETY_RESERVE_MB = 1024
26
26
 
27
+ # When --max-mem does not fit in free RAM but a smaller heap still leaves this
28
+ # much headroom over the estimate, jadx runs with that smaller -Xmx instead of
29
+ # the scan being refused. -Xmx bounds the JVM, so the smaller heap is what keeps
30
+ # memory in check. Measured: four real apps (6.5k to 60k classes) decompiled
31
+ # fully at 1.5x the estimate, peak RSS about 1.2x that heap.
32
+ AUTO_LOWER_MARGIN = 1.5
33
+
27
34
 
28
35
  class DexStats(NamedTuple):
29
36
  methods: int
@@ -41,6 +48,8 @@ class PreflightVerdict(NamedTuple):
41
48
  max_mem_mb: int
42
49
  available_mb: Optional[int]
43
50
  stats: DexStats
51
+ # -Xmx value to run jadx with when it differs from --max-mem, else None.
52
+ lowered_max_mem: Optional[str] = None
44
53
 
45
54
 
46
55
  def parse_mem_arg(value: str) -> Optional[int]:
@@ -71,19 +80,7 @@ def available_ram_mb() -> Optional[int]:
71
80
  if line.startswith("MemAvailable:"):
72
81
  return int(line.split()[1]) // 1024
73
82
  elif system == "Darwin":
74
- # vm_stat exposes reclaimable inactive+purgeable pools that SC_AVPHYS_PAGES omits.
75
- import subprocess
76
- out = subprocess.run(["vm_stat"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10).stdout
77
- page = 4096
78
- pm = re.search(r"page size of (\d+) bytes", out)
79
- if pm:
80
- page = int(pm.group(1))
81
- counts = dict(re.findall(r"^(.+?):\s+(\d+)\.", out, re.M))
82
- free = int(counts.get("Pages free", 0))
83
- inactive = int(counts.get("Pages inactive", 0))
84
- purgeable = int(counts.get("Pages purgeable", 0))
85
- if free or inactive:
86
- return ((free + inactive + purgeable) * page) // (1024 * 1024)
83
+ return _darwin_available_mb()
87
84
  elif system == "Windows":
88
85
  class _MemStatusEx(ctypes.Structure):
89
86
  _fields_ = [
@@ -109,6 +106,58 @@ def available_ram_mb() -> Optional[int]:
109
106
  return None
110
107
 
111
108
 
109
+ def _sysctl_int(name: str) -> Optional[int]:
110
+ import subprocess
111
+ try:
112
+ out = subprocess.run(["sysctl", "-n", name], capture_output=True, text=True,
113
+ encoding="utf-8", errors="replace", timeout=10).stdout.strip()
114
+ except (OSError, subprocess.SubprocessError):
115
+ return None
116
+ return int(out) if out.isdigit() else None
117
+
118
+
119
+ def _vm_stat_available_mb(out: str) -> Optional[int]:
120
+ """free + inactive + speculative + purgeable pages from `vm_stat` output, in MB."""
121
+ page = 4096
122
+ pm = re.search(r"page size of (\d+) bytes", out)
123
+ if pm:
124
+ page = int(pm.group(1))
125
+ counts = dict(re.findall(r"^(.+?):\s+(\d+)\.", out, re.M))
126
+ pages = sum(int(counts.get(k, 0)) for k in
127
+ ("Pages free", "Pages inactive", "Pages speculative", "Pages purgeable"))
128
+ if not pages:
129
+ return None
130
+ return (pages * page) // (1024 * 1024)
131
+
132
+
133
+ def _darwin_available_mb(memsize: Optional[int] = None, level: Optional[int] = None,
134
+ vm_stat_out: Optional[str] = None) -> Optional[int]:
135
+ """Available memory on macOS.
136
+
137
+ The kernel's own figure is kern.memorystatus_level (the "free percentage"
138
+ `memory_pressure` prints), which counts memory the compressor can reclaim.
139
+ Page counts from vm_stat miss that and read a few GB low on a machine that
140
+ is not under pressure at all, so they are only the fallback.
141
+ """
142
+ import subprocess
143
+ if memsize is None:
144
+ memsize = _sysctl_int("hw.memsize")
145
+ if level is None:
146
+ level = _sysctl_int("kern.memorystatus_level")
147
+ from_level = None
148
+ if memsize and level is not None and 0 < level <= 100:
149
+ from_level = (memsize * level // 100) // (1024 * 1024)
150
+ if vm_stat_out is None:
151
+ try:
152
+ vm_stat_out = subprocess.run(["vm_stat"], capture_output=True, text=True,
153
+ encoding="utf-8", errors="replace", timeout=10).stdout
154
+ except (OSError, subprocess.SubprocessError):
155
+ vm_stat_out = ""
156
+ from_pages = _vm_stat_available_mb(vm_stat_out or "")
157
+ known = [v for v in (from_level, from_pages) if v is not None]
158
+ return max(known) if known else None
159
+
160
+
112
161
  def _is_app_dex(name: str) -> bool:
113
162
  """True for app-code DEX (APK `classes*.dex` or App Bundle `<module>/dex/classes*.dex`), not asset .dex."""
114
163
  base = name.rsplit("/", 1)[-1]
@@ -225,8 +274,20 @@ def check_memory_preflight(apk_path: str, max_mem: str,
225
274
 
226
275
  if available_mb is not None:
227
276
  needed_rss = int(max_mem_mb * JVM_RSS_OVERHEAD)
228
- if needed_rss > (available_mb - RAM_SAFETY_RESERVE_MB):
229
- safe_gb = max(1, int((available_mb - RAM_SAFETY_RESERVE_MB) / JVM_RSS_OVERHEAD / 1024))
277
+ room_mb = available_mb - RAM_SAFETY_RESERVE_MB
278
+ if needed_rss > room_mb:
279
+ # -Xmx is a ceiling the JVM only grows to under pressure: when a smaller
280
+ # heap still fits this app with room to spare, use that instead of refusing.
281
+ fit_mb = (max(0, int(room_mb / JVM_RSS_OVERHEAD)) // 256) * 256
282
+ if fit_mb >= max(MIN_ESTIMATE_MB, need_mb * AUTO_LOWER_MARGIN):
283
+ note = (
284
+ f"Only {_fmt_mb(available_mb)} of memory is available right now, so JADX runs "
285
+ f"with a {_fmt_mb(fit_mb)} Java heap instead of --max-mem {max_mem} "
286
+ f"(this app needs about {_fmt_mb(need_mb)})."
287
+ )
288
+ return PreflightVerdict(False, "lowered", note, need_mb, max_mem_mb,
289
+ available_mb, stats, f"{fit_mb}m")
290
+ safe_gb = max(1, int(room_mb / JVM_RSS_OVERHEAD / 1024))
230
291
  lower_line = f" - lower to --max-mem {safe_gb}g, which fits in what's free"
231
292
  if safe_gb * 1024 < need_mb:
232
293
  lower_line += (f" (though this app looks like it needs about {_fmt_mb(need_mb)}, "
@@ -234,7 +295,8 @@ def check_memory_preflight(apk_path: str, max_mem: str,
234
295
  msg = (
235
296
  f"--max-mem {max_mem} asks JADX for {_fmt_mb(max_mem_mb)} of Java heap, which needs "
236
297
  f"about {_fmt_mb(needed_rss)} of real memory once JVM overhead is counted. This "
237
- f"machine only has {_fmt_mb(available_mb)} available right now.\n\n"
298
+ f"machine only has {_fmt_mb(available_mb)} available right now, not enough for "
299
+ f"this app ({scale}, about {_fmt_mb(need_mb)} of heap).\n\n"
238
300
  f"Running this would push the machine into swap and most likely get JADX killed by "
239
301
  f"the OS out-of-memory killer (and may take other apps down with it). Options:\n"
240
302
  f" - free up memory and try again\n"
@@ -222,6 +222,10 @@ def _as_limit_preexec(max_mem_mb):
222
222
  return _set_limits
223
223
 
224
224
 
225
+ # Set by decompile_apk when it ran jadx with a smaller heap than --max-mem; the caller prints it.
226
+ LAST_HEAP_NOTE = None
227
+
228
+
225
229
  def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bool = False,
226
230
  extra_inputs=None):
227
231
  """Decompile an APK with jadx; returns (success, error_detail). jadx keeps the FIRST input's manifest, so apk_path stays first; `extra_inputs` merge into one tree."""
@@ -229,10 +233,16 @@ def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bo
229
233
  if validation_error:
230
234
  return False, validation_error
231
235
 
236
+ global LAST_HEAP_NOTE
237
+ LAST_HEAP_NOTE = None
232
238
  if not force:
233
239
  verdict = check_memory_preflight(apk_path, max_mem)
234
240
  if verdict.should_block:
235
241
  return False, verdict.message
242
+ lowered = getattr(verdict, "lowered_max_mem", None)
243
+ if lowered:
244
+ max_mem = lowered
245
+ LAST_HEAP_NOTE = verdict.message
236
246
 
237
247
  try:
238
248
  jadx_bin = resolve_jadx_binary()
@@ -37,6 +37,7 @@ except Exception:
37
37
  from narvy import __version__
38
38
  from narvy.reporter import generate_sarif_report, sarif_level, sarif_security_severity
39
39
  from .decompiler import decompile_apk
40
+ from . import decompiler as _decompiler
40
41
  from .rule_engine import load_rules_from_dir, run_rules_on_file
41
42
  from . import auth, own_reports, uploader, telemetry
42
43
  from .third_party_filter import get_own_package_roots, resolve_file_scope, check_android_override
@@ -866,6 +867,8 @@ def _run_local_scan(apk_path, max_mem, override_config=None, force=False,
866
867
  console.print(f"\n[bold red]{decompile_error}[/bold red]\n")
867
868
  return None
868
869
  progress.update(task1, completed=1, description="[green]Decompilation Complete.")
870
+ if _decompiler.LAST_HEAP_NOTE:
871
+ console.print(f"[dim]{_decompiler.LAST_HEAP_NOTE}[/dim]")
869
872
 
870
873
  task2 = progress.add_task("[cyan]Analyzing files...", total=None)
871
874
  rules_path = os.path.join(os.path.dirname(__file__), 'rules', 'android')
@@ -231,6 +231,50 @@ def load_rule_defs(rules_path: str = RULES_PATH) -> List[Dict[str, Any]]:
231
231
  return defs
232
232
 
233
233
 
234
+ def _attempt(cmd: List[str], timeout: int):
235
+ """One semgrep run: (status, data, message). Raises TimeoutExpired."""
236
+ proc = run_tree(cmd, timeout=timeout, env=semgrep_env())
237
+ if not proc.stdout:
238
+ if proc.returncode not in (0, 1):
239
+ return 'error', None, _rc_message(proc)
240
+ return 'ok', {'results': []}, None
241
+ try:
242
+ data = json.loads(proc.stdout)
243
+ except json.JSONDecodeError:
244
+ return 'error', None, "semgrep output was not valid JSON"
245
+ if not isinstance(data, dict):
246
+ return 'error', None, "semgrep output was not valid JSON"
247
+ if proc.returncode not in (0, 1) and not data.get('results'):
248
+ # Exit 2+ with a JSON body is a fatal error (bad config, crash), not a clean pass.
249
+ errs = [e.get('message') or e.get('type') for e in data.get('errors') or []
250
+ if isinstance(e, dict)]
251
+ detail = next((str(e) for e in errs if e), None)
252
+ return 'error', None, _rc_message(proc, detail)
253
+ return 'ok', data, None
254
+
255
+
256
+ def _rc_message(proc, detail: Optional[str] = None) -> str:
257
+ text = detail or (proc.stderr or '').strip()
258
+ text = ' '.join(str(text).split())[:200]
259
+ return f"semgrep exited rc={proc.returncode}" + (f": {text}" if text else "")
260
+
261
+
262
+ def _scanned_nothing(data: Optional[Dict[str, Any]]) -> bool:
263
+ paths = (data or {}).get('paths')
264
+ return isinstance(paths, dict) and 'scanned' in paths and not paths['scanned']
265
+
266
+
267
+ def _count_own_code_files(source_dir: str, own_roots) -> int:
268
+ from .third_party_filter import is_own_package_path
269
+ n = 0
270
+ for dirpath, _dirs, files in os.walk(source_dir):
271
+ for f in files:
272
+ if f.endswith(('.java', '.kt')) and is_own_package_path(
273
+ os.path.relpath(os.path.join(dirpath, f), source_dir), own_roots):
274
+ n += 1
275
+ return n
276
+
277
+
234
278
  def run_semgrep(source_dir: str, timeout: Optional[int] = None,
235
279
  own_roots: Optional[set] = None,
236
280
  override_config: Optional[ScopeConfig] = None,
@@ -267,33 +311,37 @@ def run_semgrep(source_dir: str, timeout: Optional[int] = None,
267
311
  # suppresses a vendor entry nested inside an included own_root.
268
312
  scope_args += ['--exclude', f"**/{entry.replace('.', '/')}/**"]
269
313
 
270
- cmd = (
271
- [semgrep_bin() or 'semgrep', '--config', RULES_PATH, source_dir,
272
- '--json', '--quiet', '--no-git-ignore',
273
- '--timeout', '60'] # per-file timeout, not the whole run
274
- + resource_args(semgrep_jobs())
275
- + scope_args
276
- )
314
+ def _cmd(jobs: int, scope: List[str]) -> List[str]:
315
+ return (
316
+ [semgrep_bin() or 'semgrep', '--config', RULES_PATH, source_dir,
317
+ '--json', '--quiet', '--no-git-ignore', '--no-rewrite-rule-ids',
318
+ '--timeout', '60'] # per-file timeout, not the whole run
319
+ + resource_args(jobs)
320
+ + scope
321
+ )
322
+
323
+ jobs = semgrep_jobs()
277
324
  try:
278
- proc = run_tree(cmd, timeout=timeout, env=semgrep_env())
325
+ status, data, message = _attempt(_cmd(jobs, scope_args), timeout)
326
+ if status == 'error' and jobs > 1:
327
+ # A crashed multi-worker run is retried once with one worker before
328
+ # the pass is reported as incomplete.
329
+ status, data, message = _attempt(_cmd(1, scope_args), timeout)
279
330
  except subprocess.TimeoutExpired:
280
331
  # LAST_RUN stops the caller presenting the empty list as a clean pass.
281
332
  _record_run('timeout', timeout_s=timeout, file_count=file_count)
282
333
  return []
283
334
 
284
- if not proc.stdout:
285
- if proc.returncode not in (0, 1):
286
- _record_run('error', timeout_s=timeout, file_count=file_count,
287
- message=f"semgrep exited rc={proc.returncode}")
288
- else:
289
- _record_run('ok', timeout_s=timeout, file_count=file_count)
290
- return []
335
+ if status == 'ok' and own_roots and _scanned_nothing(data):
336
+ expected = _count_own_code_files(source_dir, own_roots)
337
+ if expected:
338
+ # The --include globs matched none of the app's own files: say so
339
+ # instead of reporting a clean pass over nothing.
340
+ status, message = 'error', (
341
+ f"the structural pass matched none of the {expected} app source files")
291
342
 
292
- try:
293
- data = json.loads(proc.stdout)
294
- except json.JSONDecodeError:
295
- _record_run('error', timeout_s=timeout, file_count=file_count,
296
- message="semgrep output was not valid JSON")
343
+ if status != 'ok':
344
+ _record_run('error', timeout_s=timeout, file_count=file_count, message=message)
297
345
  return []
298
346
 
299
347
  _record_run('ok', timeout_s=timeout, file_count=file_count)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narvy-cli
3
- Version: 1.1.2
3
+ Version: 1.1.3
4
4
  Summary: Free, local SAST scanner for Android, iOS, and polyglot web/backend source - plus passive web, host-audit, and cloud-posture scanning.
5
5
  Author: Narvy
6
6
  License: Apache-2.0
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes