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.
- narvy/__init__.py +3 -0
- narvy/android/__init__.py +0 -0
- narvy/android/source_analyzer.py +268 -0
- narvy/android/split_bundle.py +475 -0
- narvy/android_rule_context.py +196 -0
- narvy/apk_memory_preflight.py +248 -0
- narvy/auth.py +40 -0
- narvy/ci_templates/bitbucket.yml +25 -0
- narvy/ci_templates/github.yml +60 -0
- narvy/ci_templates/gitlab.yml +32 -0
- narvy/cloud/__init__.py +0 -0
- narvy/cloud/aws_scan.py +1188 -0
- narvy/comment_filter.py +134 -0
- narvy/crypto_taint_lite.py +82 -0
- narvy/decompiler.py +337 -0
- narvy/doctor.py +244 -0
- narvy/host/__init__.py +0 -0
- narvy/host/audit.py +157 -0
- narvy/host/host_knowledge.py +982 -0
- narvy/host/lynis_bootstrap.py +400 -0
- narvy/host/lynis_parser.py +358 -0
- narvy/host/narvy_checks.py +789 -0
- narvy/host/report.py +69 -0
- narvy/host/ssh_exec.py +219 -0
- narvy/ios/__init__.py +0 -0
- narvy/ios/binary_analyzer.py +1201 -0
- narvy/ios/plist_checks.py +249 -0
- narvy/ios/source_analyzer.py +301 -0
- narvy/ios/third_party_filter.py +242 -0
- narvy/ios/trust_all_context.py +66 -0
- narvy/main.py +1983 -0
- narvy/native_hardening.py +503 -0
- narvy/reporter.py +151 -0
- narvy/rule_engine.py +57 -0
- narvy/rules/android/config.yml +77 -0
- narvy/rules/android/crypto.yml +34 -0
- narvy/rules/android/secrets.yml +161 -0
- narvy/rules/android/storage.yml +24 -0
- narvy/rules/android/webview.yml +23 -0
- narvy/rules/android.yml +219 -0
- narvy/rules/ios/objc/crypto.yml +56 -0
- narvy/rules/ios/objc/network.yml +67 -0
- narvy/rules/ios/objc/secrets.yml +79 -0
- narvy/rules/ios/objc/storage.yml +45 -0
- narvy/rules/ios/objc/webview.yml +45 -0
- narvy/rules/ios_swift.yml +327 -0
- narvy/rules/web/go.yml +2837 -0
- narvy/rules/web/java.yml +1576 -0
- narvy/rules/web/javascript.yml +3683 -0
- narvy/rules/web/kotlin.yml +413 -0
- narvy/rules/web/local/csharp_narvy/config.yml +61 -0
- narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
- narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
- narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
- narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
- narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
- narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
- narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
- narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
- narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
- narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
- narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
- narvy/rules/web/local/java_narvy/sqli.yml +235 -0
- narvy/rules/web/local/java_narvy/xxe.yml +212 -0
- narvy/rules/web/php.yml +1644 -0
- narvy/rules/web/python.yml +3967 -0
- narvy/rules/web/ruby.yml +703 -0
- narvy/rules/web/rust.yml +258 -0
- narvy/rules/web/secrets.yml +1420 -0
- narvy/rules/web/secrets_supplement.yml +383 -0
- narvy/sca/__init__.py +1 -0
- narvy/sca/android_deps.py +349 -0
- narvy/sca/ios_deps.py +578 -0
- narvy/sca/osv_client.py +617 -0
- narvy/sca/web_deps.py +955 -0
- narvy/scope_config.py +195 -0
- narvy/semgrep_engine.py +219 -0
- narvy/stack_protector_evidence.py +96 -0
- narvy/third_party_filter.py +211 -0
- narvy/uploader.py +92 -0
- narvy/weak_prng_context.py +270 -0
- narvy/web/__init__.py +0 -0
- narvy/web/nuclei_binary.py +105 -0
- narvy/web/scan_blocklist.py +96 -0
- narvy/web/scanner.py +842 -0
- narvy/web/source_analyzer.py +714 -0
- narvy/web/ssrf_guard.py +374 -0
- narvy_cli-1.0.0.dist-info/METADATA +165 -0
- narvy_cli-1.0.0.dist-info/RECORD +93 -0
- narvy_cli-1.0.0.dist-info/WHEEL +5 -0
- narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
- narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
- narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/comment_filter.py
ADDED
|
@@ -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
|
+
)
|