narvy-cli 1.0.0__py3-none-any.whl

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/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,134 @@
1
+ """Comment-aware masking for C-family source (Java, Kotlin, Objective-C, Swift):
2
+ mark byte offsets inside `//` and `/* */` comments so regex rules don't fire on
3
+ commented-out code. String literals excluded; when unsure, treated as code (never drops a finding)."""
4
+ from __future__ import annotations
5
+
6
+ from typing import List
7
+
8
+ _ST_CODE = 0
9
+ _ST_LINE = 1
10
+ _ST_BLOCK = 2
11
+
12
+
13
+ def build_comment_mask(content: str) -> List[bool]:
14
+ """Return mask where mask[i] is True iff content[i] is inside a comment."""
15
+ n = len(content)
16
+ mask = [False] * n
17
+ state = _ST_CODE
18
+ block_depth = 0
19
+ i = 0
20
+ while i < n:
21
+ c = content[i]
22
+
23
+ if state == _ST_LINE:
24
+ mask[i] = True
25
+ if c == "\n":
26
+ state = _ST_CODE
27
+ i += 1
28
+ continue
29
+
30
+ if state == _ST_BLOCK:
31
+ mask[i] = True
32
+ # Kotlin/Swift allow nested block comments.
33
+ if c == "/" and i + 1 < n and content[i + 1] == "*":
34
+ block_depth += 1
35
+ mask[i + 1] = True
36
+ i += 2
37
+ continue
38
+ if c == "*" and i + 1 < n and content[i + 1] == "/":
39
+ block_depth -= 1
40
+ mask[i + 1] = True
41
+ i += 2
42
+ if block_depth == 0:
43
+ state = _ST_CODE
44
+ continue
45
+ i += 1
46
+ continue
47
+
48
+ if c == "/" and i + 1 < n:
49
+ nxt = content[i + 1]
50
+ if nxt == "/":
51
+ state = _ST_LINE
52
+ mask[i] = True
53
+ i += 1
54
+ continue
55
+ if nxt == "*":
56
+ state = _ST_BLOCK
57
+ block_depth = 1
58
+ mask[i] = True
59
+ i += 1
60
+ continue
61
+
62
+ # Swift raw string: optional leading '#'s then a quote.
63
+ if c == "#":
64
+ j = i
65
+ while j < n and content[j] == "#":
66
+ j += 1
67
+ hashes = j - i
68
+ if hashes > 0 and j < n and content[j] == '"':
69
+ # Closing delimiter is '"' followed by `hashes` '#'.
70
+ closing = '"' + ("#" * hashes)
71
+ end = content.find(closing, j + 1)
72
+ if end == -1:
73
+ i = n
74
+ else:
75
+ i = end + len(closing)
76
+ continue
77
+ i += 1
78
+ continue
79
+
80
+ # Triple-quoted string (Swift multiline / Kotlin / Java text block).
81
+ if c == '"' and content.startswith('"""', i):
82
+ end = content.find('"""', i + 3)
83
+ if end == -1:
84
+ i = n
85
+ else:
86
+ i = end + 3
87
+ continue
88
+
89
+ # Normal double/single-quoted string with backslash escapes.
90
+ if c == '"' or c == "'":
91
+ quote = c
92
+ i += 1
93
+ while i < n:
94
+ cc = content[i]
95
+ if cc == "\\":
96
+ i += 2
97
+ continue
98
+ if cc == quote:
99
+ i += 1
100
+ break
101
+ if cc == "\n":
102
+ # Unterminated string: stop at newline.
103
+ break
104
+ i += 1
105
+ continue
106
+
107
+ i += 1
108
+
109
+ return mask
110
+
111
+
112
+ def line_col_to_offset(content: str, line: int, col: int) -> int:
113
+ """Convert a 1-based Semgrep (line, col) to a 0-based char offset, -1 if out of range."""
114
+ if line < 1:
115
+ return -1
116
+ idx = 0
117
+ cur_line = 1
118
+ while cur_line < line:
119
+ nl = content.find("\n", idx)
120
+ if nl == -1:
121
+ return -1
122
+ idx = nl + 1
123
+ cur_line += 1
124
+ off = idx + max(col - 1, 0)
125
+ if off >= len(content):
126
+ return -1
127
+ return off
128
+
129
+
130
+ def line_col_in_comment(content: str, line: int, col: int, mask: List[bool]) -> bool:
131
+ off = line_col_to_offset(content, line, col)
132
+ if off < 0 or off >= len(mask):
133
+ return False
134
+ return mask[off]
@@ -0,0 +1,82 @@
1
+ """Name-based correlation pass for hardcoded key and IV material reaching
2
+ javax.crypto.spec.{SecretKeySpec,IvParameterSpec} through one level of
3
+ indirection. Correlation is by variable name within a single file, not scope.
4
+ """
5
+ from __future__ import annotations
6
+
7
+ import re
8
+ from typing import Any, Dict, List
9
+
10
+ _LITERAL_STRING_ASSIGN = re.compile(
11
+ r'\b(?:private\s+|public\s+|protected\s+|static\s+|final\s+)*'
12
+ r'String\s+(\w+)\s*=\s*"([^"]{8,})"'
13
+ )
14
+ _LITERAL_ARRAY_ASSIGN = re.compile(
15
+ r'\b(?:private\s+|public\s+|protected\s+|static\s+|final\s+)*'
16
+ r'byte\[\]\s+(\w+)\s*=\s*\{[\d,\s]+\}'
17
+ )
18
+ _GETBYTES_ALIAS = re.compile(
19
+ r'\b(\w+)\s*=\s*(?:this\.)?(\w+)\.getBytes\('
20
+ )
21
+ _SINK = re.compile(
22
+ r'new\s+(SecretKeySpec|IvParameterSpec)\s*\(\s*(?:this\.)?(\w+)'
23
+ )
24
+
25
+ _RULE_INFO = {
26
+ "SecretKeySpec": {
27
+ "rule_id": "AND-CRYPTO-010",
28
+ "name": "Hardcoded Symmetric Encryption Key (indirect)",
29
+ "severity": "CRITICAL",
30
+ "cwe": "CWE-321",
31
+ "masvs": "MSTG-CRYPTO-1",
32
+ "description": "A symmetric encryption key is built from a variable that traces back to a hardcoded string literal elsewhere in this file. Anyone who decompiles the app recovers the exact key used to encrypt/decrypt all protected data.",
33
+ "recommendation": "Never embed encryption keys in source code, even indirectly through a named field. Derive keys via the Android Keystore system, or retrieve them from a secure backend at runtime.",
34
+ },
35
+ "IvParameterSpec": {
36
+ "rule_id": "AND-CRYPTO-009",
37
+ "name": "Hardcoded Cryptographic IV (indirect)",
38
+ "severity": "HIGH",
39
+ "cwe": "CWE-329",
40
+ "masvs": "MSTG-CRYPTO-3",
41
+ "description": "A hardcoded, static initialization vector (IV) - traced back to a literal byte array elsewhere in this file - is used for encryption. Reusing a static IV defeats the security properties of most cipher modes.",
42
+ "recommendation": "Generate a fresh, random IV for every encryption operation using SecureRandom, and store/transmit it alongside the ciphertext (it does not need to be secret).",
43
+ },
44
+ }
45
+
46
+
47
+ def check_file(content: str) -> List[Dict[str, Any]]:
48
+ literal_vars = set()
49
+ for m in _LITERAL_STRING_ASSIGN.finditer(content):
50
+ literal_vars.add(m.group(1))
51
+ for m in _LITERAL_ARRAY_ASSIGN.finditer(content):
52
+ literal_vars.add(m.group(1))
53
+ if not literal_vars:
54
+ return []
55
+
56
+ for m in _GETBYTES_ALIAS.finditer(content):
57
+ alias, source = m.group(1), m.group(2)
58
+ if source in literal_vars:
59
+ literal_vars.add(alias)
60
+
61
+ findings = []
62
+ for m in _SINK.finditer(content):
63
+ ctor, arg_var = m.group(1), m.group(2)
64
+ if arg_var not in literal_vars:
65
+ continue
66
+ info = _RULE_INFO[ctor]
67
+ line = content.count("\n", 0, m.start()) + 1
68
+ findings.append({
69
+ "rule_id": info["rule_id"],
70
+ "name": info["name"],
71
+ "severity": info["severity"],
72
+ "line": line,
73
+ "confidence": "MEDIUM",
74
+ "engine": "crypto_taint_lite",
75
+ "details": {
76
+ "cwe": info["cwe"],
77
+ "masvs": info["masvs"],
78
+ "description": info["description"],
79
+ "recommendation": info["recommendation"],
80
+ },
81
+ })
82
+ return findings
narvy/decompiler.py ADDED
@@ -0,0 +1,337 @@
1
+ import subprocess
2
+ import os
3
+ import platform
4
+ import re
5
+ import shutil
6
+ import stat
7
+ import tarfile
8
+ import zipfile
9
+ import logging
10
+
11
+ import requests
12
+
13
+ from .apk_memory_preflight import (
14
+ JVM_RSS_OVERHEAD,
15
+ check_memory_preflight,
16
+ parse_mem_arg,
17
+ )
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ JADX_VERSION = "1.5.0"
22
+ JADX_URL = f"https://github.com/skylot/jadx/releases/download/v{JADX_VERSION}/jadx-{JADX_VERSION}.zip"
23
+ TOOLS_DIR = os.path.expanduser("~/.narvy/tools")
24
+ JADX_HOME = os.path.join(TOOLS_DIR, f"jadx-{JADX_VERSION}")
25
+ _IS_WINDOWS = platform.system() == "Windows"
26
+ _IS_MAC = platform.system() == "Darwin"
27
+ # jadx ships a Unix shell script and a Windows .bat; the .bat avoids WinError 193.
28
+ JADX_BIN = os.path.join(JADX_HOME, "bin", "jadx.bat" if _IS_WINDOWS else "jadx")
29
+
30
+ # jadx 1.5.0 needs Java 11+; a JRE is fetched when the system has none.
31
+ _JRE_MAJOR = "17"
32
+ _JRE_ADOPTIUM_OS = "windows" if _IS_WINDOWS else ("mac" if _IS_MAC else "linux")
33
+ # x64 only: ARM Linux is unsupported and fails loudly on the archive.
34
+ _JRE_ADOPTIUM_ARCH = "x64"
35
+ JRE_URL = (
36
+ f"https://api.adoptium.net/v3/binary/latest/{_JRE_MAJOR}/ga/"
37
+ f"{_JRE_ADOPTIUM_OS}/{_JRE_ADOPTIUM_ARCH}/jre/hotspot/normal/eclipse"
38
+ )
39
+ JRE_HOME = os.path.join(TOOLS_DIR, f"jre-{_JRE_MAJOR}")
40
+
41
+
42
+ def _download_jadx():
43
+ os.makedirs(TOOLS_DIR, exist_ok=True)
44
+ zip_path = os.path.join(TOOLS_DIR, f"jadx-{JADX_VERSION}.zip")
45
+ logger.info(f"jadx not found on PATH - downloading {JADX_URL}")
46
+ resp = requests.get(JADX_URL, stream=True, timeout=120)
47
+ resp.raise_for_status()
48
+ with open(zip_path, "wb") as f:
49
+ for chunk in resp.iter_content(chunk_size=1 << 20):
50
+ f.write(chunk)
51
+ with zipfile.ZipFile(zip_path) as z:
52
+ z.extractall(JADX_HOME)
53
+ os.remove(zip_path)
54
+ if not _IS_WINDOWS:
55
+ st = os.stat(JADX_BIN)
56
+ os.chmod(JADX_BIN, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
57
+ logger.info(f"jadx {JADX_VERSION} installed to {JADX_HOME}")
58
+
59
+
60
+ def resolve_jadx_binary():
61
+ on_path = shutil.which("jadx")
62
+ if on_path:
63
+ return on_path
64
+ if os.path.exists(JADX_BIN):
65
+ return JADX_BIN
66
+ _download_jadx()
67
+ return JADX_BIN
68
+
69
+
70
+ def _java_major_version(java_bin):
71
+ try:
72
+ result = subprocess.run([java_bin, "-version"], capture_output=True, text=True, timeout=10)
73
+ except Exception:
74
+ return None
75
+ out = result.stderr or result.stdout or ""
76
+ m = re.search(r'version "(\d+)(?:\.(\d+))?', out)
77
+ if not m:
78
+ return None
79
+ major = int(m.group(1))
80
+ if major == 1 and m.group(2): # old "1.x" scheme: the real major is second
81
+ major = int(m.group(2))
82
+ return major
83
+
84
+
85
+ def _jre_bin_dir():
86
+ """The bin/ dir inside the downloaded JRE, once extracted."""
87
+ # Discovered, not hardcoded: the Adoptium top-level dir carries the patch version.
88
+ if not os.path.isdir(JRE_HOME):
89
+ return None
90
+ for entry in os.listdir(JRE_HOME):
91
+ candidate = os.path.join(JRE_HOME, entry, "bin")
92
+ if os.path.isdir(candidate):
93
+ return candidate
94
+ return None
95
+
96
+
97
+ def _download_jre():
98
+ os.makedirs(TOOLS_DIR, exist_ok=True)
99
+ is_zip = _IS_WINDOWS
100
+ archive_path = os.path.join(TOOLS_DIR, f"jre-{_JRE_MAJOR}.{'zip' if is_zip else 'tar.gz'}")
101
+ logger.info(f"No usable Java 11+ found - downloading a JRE ourselves from {JRE_URL}")
102
+ resp = requests.get(JRE_URL, stream=True, timeout=180, allow_redirects=True)
103
+ resp.raise_for_status()
104
+ with open(archive_path, "wb") as f:
105
+ for chunk in resp.iter_content(chunk_size=1 << 20):
106
+ f.write(chunk)
107
+ if os.path.exists(JRE_HOME):
108
+ shutil.rmtree(JRE_HOME)
109
+ os.makedirs(JRE_HOME, exist_ok=True)
110
+ if is_zip:
111
+ with zipfile.ZipFile(archive_path) as z:
112
+ z.extractall(JRE_HOME)
113
+ else:
114
+ with tarfile.open(archive_path) as t:
115
+ t.extractall(JRE_HOME)
116
+ os.remove(archive_path)
117
+ bin_dir = _jre_bin_dir()
118
+ if not bin_dir:
119
+ raise RuntimeError(f"JRE archive extracted to {JRE_HOME} but no bin/ dir found inside - bad archive or unexpected layout")
120
+ if not _IS_WINDOWS:
121
+ java_bin = os.path.join(bin_dir, "java")
122
+ st = os.stat(java_bin)
123
+ os.chmod(java_bin, st.st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
124
+ logger.info(f"JRE {_JRE_MAJOR} installed to {bin_dir}")
125
+ return bin_dir
126
+
127
+
128
+ def resolve_java_home():
129
+ """Return a bin/ dir for jadx (None if system java is 11+); downloads a JRE if needed."""
130
+ system_java = shutil.which("java")
131
+ if system_java and (_java_major_version(system_java) or 0) >= 11:
132
+ return None
133
+
134
+ bin_dir = _jre_bin_dir()
135
+ if bin_dir:
136
+ return bin_dir
137
+
138
+ return _download_jre()
139
+
140
+
141
+ def _jadx_cmd(jadx_bin, extra_args):
142
+ # A .bat needs `cmd /c`; shell=True would mis-quote paths.
143
+ if _IS_WINDOWS and jadx_bin.lower().endswith(".bat"):
144
+ return ["cmd", "/c", jadx_bin] + extra_args
145
+ return [jadx_bin] + extra_args
146
+
147
+
148
+ def _validate_apk_zip(apk_path: str):
149
+ """Return None if the file looks like Android build output, else an error (jadx exits 0 on an empty/non-Android zip). Never raises."""
150
+ try:
151
+ with zipfile.ZipFile(apk_path, "r") as zf:
152
+ names = zf.namelist()
153
+ except zipfile.BadZipFile as e:
154
+ return f"Could not open {apk_path} as a zip archive: {e}. Not a valid APK/AAB?"
155
+ except OSError as e:
156
+ return f"Could not read {apk_path}: {e}"
157
+
158
+ if not names:
159
+ return f"{apk_path} is an empty archive (0 entries) - not a valid APK/AAB."
160
+
161
+ has_manifest_or_dex = any(
162
+ n.endswith("AndroidManifest.xml") or n.endswith(".dex") for n in names
163
+ )
164
+ if not has_manifest_or_dex:
165
+ return (
166
+ f"{apk_path} doesn't look like a valid APK/AAB - no AndroidManifest.xml "
167
+ f"or .dex file found anywhere in the archive (found {len(names)} other "
168
+ f"entries). If this is meant to be an Android app, check the file wasn't "
169
+ f"truncated or is the wrong file."
170
+ )
171
+ return None
172
+
173
+
174
+ def _as_limit_preexec(max_mem_mb):
175
+ """Return a preexec_fn capping the jadx child's address space, or None. POSIX only."""
176
+ # Cap is 2x heap + 3 GB: a JVM reserves far more address space than its heap.
177
+ if _IS_WINDOWS or not max_mem_mb:
178
+ return None
179
+ try:
180
+ import resource
181
+ except ImportError:
182
+ return None
183
+
184
+ limit_bytes = int((max_mem_mb * 2 + 3072) * 1024 * 1024)
185
+
186
+ def _set_limits():
187
+ try:
188
+ soft, hard = resource.getrlimit(resource.RLIMIT_AS)
189
+ # Never raise an existing limit: a CI runner may have set a tighter one.
190
+ target = limit_bytes
191
+ if hard != resource.RLIM_INFINITY:
192
+ target = min(target, hard)
193
+ if soft != resource.RLIM_INFINITY:
194
+ target = min(target, soft)
195
+ resource.setrlimit(resource.RLIMIT_AS, (target, hard))
196
+ except Exception:
197
+ pass
198
+
199
+ return _set_limits
200
+
201
+
202
+ def decompile_apk(apk_path: str, output_dir: str, max_mem: str = "4g", force: bool = False,
203
+ extra_inputs=None):
204
+ """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."""
205
+ validation_error = _validate_apk_zip(apk_path)
206
+ if validation_error:
207
+ return False, validation_error
208
+
209
+ if not force:
210
+ verdict = check_memory_preflight(apk_path, max_mem)
211
+ if verdict.should_block:
212
+ return False, verdict.message
213
+
214
+ try:
215
+ jadx_bin = resolve_jadx_binary()
216
+ except Exception as e:
217
+ return False, f"Could not obtain jadx: {e}. Run `narvy doctor` to check your setup."
218
+
219
+ try:
220
+ jre_bin_dir = resolve_java_home()
221
+ except Exception as e:
222
+ return False, (
223
+ f"No usable Java found, and auto-downloading one failed: {e}. "
224
+ "Check your internet connection can reach api.adoptium.net and "
225
+ "github.com, or install a JRE 11+ yourself (https://adoptium.net) "
226
+ "and put it on PATH. Run `narvy doctor` to check your setup."
227
+ )
228
+
229
+ if os.path.exists(output_dir):
230
+ shutil.rmtree(output_dir)
231
+ os.makedirs(output_dir, exist_ok=True)
232
+
233
+ jadx_env = os.environ.copy()
234
+ jadx_env['JADX_OPTS'] = f'-Xmx{max_mem}'
235
+
236
+ if jre_bin_dir:
237
+ # jadx.bat reads JAVA_HOME, the Unix launcher just calls `java`.
238
+ jadx_env['JAVA_HOME'] = os.path.dirname(jre_bin_dir)
239
+ jadx_env['PATH'] = jre_bin_dir + os.pathsep + jadx_env.get('PATH', '')
240
+ else:
241
+ # jadx.bat prefers JAVA_HOME over PATH; drop a stale one for this subprocess only.
242
+ java_home = jadx_env.get('JAVA_HOME')
243
+ if java_home:
244
+ candidate = os.path.join(java_home, 'bin', 'java.exe' if _IS_WINDOWS else 'java')
245
+ if not os.path.isfile(candidate) or (_java_major_version(candidate) or 0) < 11:
246
+ logger.warning(f"JAVA_HOME ({java_home}) is broken or too old - ignoring it for this scan only, falling back to PATH java")
247
+ del jadx_env['JAVA_HOME']
248
+
249
+ cmd = _jadx_cmd(
250
+ jadx_bin,
251
+ ['--output-dir', output_dir, '--show-bad-code', apk_path] + list(extra_inputs or []),
252
+ )
253
+
254
+ run_kwargs = {}
255
+ preexec = _as_limit_preexec(parse_mem_arg(max_mem))
256
+ if preexec is not None:
257
+ run_kwargs["preexec_fn"] = preexec
258
+
259
+ try:
260
+ process = subprocess.run(
261
+ cmd,
262
+ capture_output=True,
263
+ text=True,
264
+ check=True,
265
+ timeout=900,
266
+ env=jadx_env,
267
+ **run_kwargs
268
+ )
269
+ return True, ""
270
+ except subprocess.CalledProcessError as e:
271
+ # jadx/cmd.exe don't consistently use stderr; report both streams.
272
+ parts = [f"JADX failed (exit code {e.returncode})", f"Command run: {' '.join(str(c) for c in cmd)}"]
273
+ if e.stdout:
274
+ parts.append(f"JADX stdout:\n{e.stdout}")
275
+ if e.stderr:
276
+ parts.append(f"JADX stderr:\n{e.stderr}")
277
+ if not e.stdout and not e.stderr:
278
+ parts.append(
279
+ "No output captured on either stdout or stderr. Run "
280
+ "`narvy doctor` to check your Java/jadx setup, or try "
281
+ f"running this exact command yourself to see the raw error: "
282
+ f"{' '.join(str(c) for c in cmd)}"
283
+ )
284
+ # OOM is unambiguous; a bare -9 (SIGKILL) is ambiguous, so they differ.
285
+ _combined_output = (e.stdout or "") + (e.stderr or "")
286
+ _jvm_oom = "OutOfMemoryError" in _combined_output
287
+ if _jvm_oom or e.returncode == -9:
288
+ if _jvm_oom:
289
+ hint = "\nJADX ran out of memory decompiling this app."
290
+ else:
291
+ hint = (
292
+ "\nJADX was killed by SIGKILL (exit -9) - killed from outside, with no "
293
+ "error of its own. There is no way to tell from the exit code alone which "
294
+ "of these it was: the OS out-of-memory killer, a container/cgroup memory "
295
+ "limit (Docker `--memory`, a CI runner's cap), a job timeout, or another "
296
+ "process on this machine. Check `dmesg -T | grep -i oom` and your "
297
+ "container/CI memory limits."
298
+ )
299
+ try:
300
+ v = check_memory_preflight(apk_path, max_mem)
301
+ suggest_gb = max(1, -(-int(v.estimated_mb * 1.25) // 1024))
302
+ lead = ("Based on" if _jvm_oom else
303
+ "\n\nIf it was memory: based on")
304
+ hint += (
305
+ f" {lead} its size (~{v.stats.methods:,} methods / "
306
+ f"~{v.stats.classes:,} classes), it needs noticeably more than the "
307
+ f"{max_mem} it was given"
308
+ )
309
+ if v.available_mb is not None:
310
+ fits = int(suggest_gb * 1024 * JVM_RSS_OVERHEAD) <= v.available_mb
311
+ hint += (
312
+ f". Try `--max-mem {suggest_gb}g`"
313
+ + ("." if fits else
314
+ f", but note this machine only has ~{v.available_mb // 1024} GB free "
315
+ f"right now - you would need to free memory first, or run this scan "
316
+ f"elsewhere (e.g. `--upload` for the hosted scan).")
317
+ )
318
+ else:
319
+ hint += f". Try `--max-mem {suggest_gb}g`."
320
+ except Exception:
321
+ hint += (
322
+ f" Try again with more headroom, e.g. `narvy scan {apk_path} "
323
+ f"--max-mem 8g`."
324
+ )
325
+ parts.append(hint)
326
+ return False, "\n".join(parts)
327
+ except subprocess.TimeoutExpired:
328
+ return False, (
329
+ f"JADX timed out after 900s decompiling {apk_path}. Large/obfuscated "
330
+ "APKs can take longer - this is a hard ceiling, not currently configurable."
331
+ )
332
+ except FileNotFoundError as e:
333
+ return False, (
334
+ f"Could not launch jadx ({jadx_bin}): {e}. Run `narvy doctor` "
335
+ "to check your setup, or delete ~/.narvy/tools and re-run "
336
+ "to force a clean re-download."
337
+ )