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.
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/CHANGELOG.md +30 -0
- {narvy_cli-1.1.2/narvy_cli.egg-info → narvy_cli-1.1.3}/PKG-INFO +1 -1
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/__init__.py +1 -1
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/apk_memory_preflight.py +78 -16
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/decompiler.py +10 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/main.py +3 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/semgrep_engine.py +68 -20
- {narvy_cli-1.1.2 → narvy_cli-1.1.3/narvy_cli.egg-info}/PKG-INFO +1 -1
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/LICENSE +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/MANIFEST.in +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/README.md +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/SECURITY.md +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/__main__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android/__init__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android/source_analyzer.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android/split_bundle.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/android_rule_context.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/auth.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ci_templates/bitbucket.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ci_templates/github.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ci_templates/gitlab.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/cloud/__init__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/cloud/aws_scan.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/comment_filter.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/crypto_taint_lite.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/doctor.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/__init__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/audit.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/host_knowledge.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/lynis_bootstrap.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/lynis_parser.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/narvy_checks.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/report.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/host/ssh_exec.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/__init__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/binary_analyzer.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/plist_checks.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/source_analyzer.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/third_party_filter.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/ios/trust_all_context.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/native_hardening.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/own_reports.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/pathhint.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/proc.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/reporter.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rule_engine.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/config.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/crypto.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/secrets.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/storage.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android/webview.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/android.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/crypto.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/network.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/secrets.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/storage.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios/objc/webview.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/ios_swift.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/go.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/java.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/javascript.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/kotlin.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/php.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/python.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/ruby.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/rust.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/secrets.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/rules/web/secrets_supplement.yml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/__init__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/android_deps.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/ios_deps.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/osv_client.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/sca/web_deps.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/scope_config.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/stack_protector_evidence.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/telemetry.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/third_party_filter.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/uploader.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/weak_prng_context.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/__init__.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/nuclei_binary.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/scan_blocklist.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/scanner.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/source_analyzer.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy/web/ssrf_guard.py +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/SOURCES.txt +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/dependency_links.txt +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/entry_points.txt +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/requires.txt +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/narvy_cli.egg-info/top_level.txt +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/pyproject.toml +0 -0
- {narvy_cli-1.1.2 → narvy_cli-1.1.3}/setup.cfg +0 -0
- {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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
293
|
-
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|