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
|
@@ -0,0 +1,503 @@
|
|
|
1
|
+
"""ELF hardening checks (NX, PIE, RELRO, stack canary, RPATH) for the native
|
|
2
|
+
.so libraries shipped inside an APK/AAB.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import tempfile
|
|
10
|
+
import zipfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
13
|
+
|
|
14
|
+
from .stack_protector_evidence import has_instrumentable_stack_code
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import lief
|
|
20
|
+
LIEF_AVAILABLE = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
LIEF_AVAILABLE = False
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
# Only one ABI is analyzed: per-ABI builds share the same hardening flags.
|
|
26
|
+
_ARCH_PREFERENCE = ("arm64-v8a", "armeabi-v7a", "x86_64", "x86")
|
|
27
|
+
|
|
28
|
+
# Zip-bomb guard on a single extracted .so.
|
|
29
|
+
_MAX_SO_BYTES = 500 * 1024 * 1024
|
|
30
|
+
|
|
31
|
+
# Lower-cased, matched against the .so basename only.
|
|
32
|
+
_VENDOR_SO_EXACT = {
|
|
33
|
+
"libc++_shared.so", "libc++abi.so", "libstlport_shared.so", "libgnustl_shared.so",
|
|
34
|
+
"libssl.so", "libcrypto.so", "libsqlite3.so", "libsqlcipher.so",
|
|
35
|
+
"libconscrypt_jni.so", "libwebviewchromium.so",
|
|
36
|
+
}
|
|
37
|
+
_VENDOR_SO_PREFIXES = (
|
|
38
|
+
"libandroidx.",
|
|
39
|
+
"libflutter", "libapp",
|
|
40
|
+
"libhermes", "libjsc", "libreactnative", "libfb", "libyoga",
|
|
41
|
+
"libil2cpp", "libmono", "libunity",
|
|
42
|
+
"libcrashlytics", "libbugsnag", "libsentry",
|
|
43
|
+
"libcronet", "libquic",
|
|
44
|
+
"libdatadog", "libgojni",
|
|
45
|
+
)
|
|
46
|
+
# A .so whose JNI entry points all sit under these namespaces is bundled third-party code.
|
|
47
|
+
_VENDOR_JNI_PACKAGE_PREFIXES = (
|
|
48
|
+
"androidx.", "android.", "com.android.",
|
|
49
|
+
"com.google.", "com.googlecode.",
|
|
50
|
+
"io.flutter.", "com.facebook.", "com.reactnativecommunity.",
|
|
51
|
+
"org.chromium.", "com.unity3d.", "org.webrtc.",
|
|
52
|
+
"io.sentry.", "com.bugsnag.", "com.crashlytics.", "com.datadog.",
|
|
53
|
+
"org.sqlite.", "net.sqlcipher.", "org.conscrypt.",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _jni_java_packages(symbol_names) -> List[str]:
|
|
58
|
+
"""Java package prefixes of every `Java_<pkg>_<Class>_<method>` JNI export."""
|
|
59
|
+
# JNI mangling maps '.'->'_' and escapes '_' as '_1'; package recoverable as a prefix only.
|
|
60
|
+
out: List[str] = []
|
|
61
|
+
for name in symbol_names:
|
|
62
|
+
if not name.startswith("Java_"):
|
|
63
|
+
continue
|
|
64
|
+
body = name[len("Java_"):]
|
|
65
|
+
if not body:
|
|
66
|
+
continue
|
|
67
|
+
out.append(body.replace("_1", "\x00").replace("_", ".").replace("\x00", "_"))
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_vendor_by_jni(symbol_names) -> bool:
|
|
72
|
+
packages = _jni_java_packages(symbol_names)
|
|
73
|
+
if not packages:
|
|
74
|
+
return False
|
|
75
|
+
return all(
|
|
76
|
+
any(pkg.startswith(prefix) for prefix in _VENDOR_JNI_PACKAGE_PREFIXES)
|
|
77
|
+
for pkg in packages
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class _SoFacts:
|
|
83
|
+
basename: str
|
|
84
|
+
zip_member: str
|
|
85
|
+
is_vendor: bool
|
|
86
|
+
has_nx: bool
|
|
87
|
+
# ET_DYN is the PIE equivalent for a .so; LIEF's is_pie also requires PT_INTERP.
|
|
88
|
+
is_dyn: bool
|
|
89
|
+
relro: str # "full" | "partial" | "none"
|
|
90
|
+
has_canary: bool
|
|
91
|
+
rpath: Optional[str]
|
|
92
|
+
runpath: Optional[str]
|
|
93
|
+
# None means no analysis available for this architecture, not "no".
|
|
94
|
+
instrumentable: Optional[bool] = None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _is_vendor_lib(basename: str, symbol_names=()) -> bool:
|
|
98
|
+
lower = basename.lower()
|
|
99
|
+
if lower in _VENDOR_SO_EXACT:
|
|
100
|
+
return True
|
|
101
|
+
if any(lower.startswith(p) for p in _VENDOR_SO_PREFIXES):
|
|
102
|
+
return True
|
|
103
|
+
return _is_vendor_by_jni(symbol_names)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _is_weak_search_path(raw: str) -> bool:
|
|
107
|
+
if not raw:
|
|
108
|
+
return False
|
|
109
|
+
for entry in raw.split(":"):
|
|
110
|
+
entry = entry.strip()
|
|
111
|
+
if not entry:
|
|
112
|
+
continue
|
|
113
|
+
if not entry.startswith("/") and not entry.startswith("$ORIGIN"):
|
|
114
|
+
return True
|
|
115
|
+
if ".." in entry:
|
|
116
|
+
return True
|
|
117
|
+
lowered = entry.lower()
|
|
118
|
+
if any(bad in lowered for bad in ("/data/local/tmp", "/sdcard", "/storage/emulated", "/tmp")):
|
|
119
|
+
return True
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _pick_arch(namelist: List[str]) -> Optional[str]:
|
|
124
|
+
available = set()
|
|
125
|
+
for name in namelist:
|
|
126
|
+
if name.startswith("lib/") and name.endswith(".so"):
|
|
127
|
+
parts = name.split("/")
|
|
128
|
+
if len(parts) >= 3 and parts[2]:
|
|
129
|
+
available.add(parts[1])
|
|
130
|
+
if not available:
|
|
131
|
+
return None
|
|
132
|
+
for pref in _ARCH_PREFERENCE:
|
|
133
|
+
if pref in available:
|
|
134
|
+
return pref
|
|
135
|
+
return sorted(available)[0]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _safe_extract(zf: zipfile.ZipFile, member: str, dest_dir: str) -> Optional[str]:
|
|
139
|
+
try:
|
|
140
|
+
info = zf.getinfo(member)
|
|
141
|
+
except KeyError:
|
|
142
|
+
return None
|
|
143
|
+
if info.file_size > _MAX_SO_BYTES:
|
|
144
|
+
logger.debug(f"[native-hardening] {member} exceeds size cap, skipping")
|
|
145
|
+
return None
|
|
146
|
+
dest_path = os.path.join(dest_dir, os.path.basename(member) + f"_{abs(hash(member))}")
|
|
147
|
+
try:
|
|
148
|
+
with zf.open(member) as src, open(dest_path, "wb") as dst:
|
|
149
|
+
shutil.copyfileobj(src, dst, length=1 << 20)
|
|
150
|
+
except Exception as e:
|
|
151
|
+
logger.debug(f"[native-hardening] extraction failed for {member}: {e}")
|
|
152
|
+
return None
|
|
153
|
+
return dest_path
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _compute_relro(binary) -> str:
|
|
157
|
+
has_seg = any(seg.type == lief.ELF.Segment.TYPE.GNU_RELRO for seg in binary.segments)
|
|
158
|
+
if not has_seg:
|
|
159
|
+
return "none"
|
|
160
|
+
bind_now = False
|
|
161
|
+
try:
|
|
162
|
+
if binary.has(lief.ELF.DynamicEntry.TAG.FLAGS):
|
|
163
|
+
entry = binary.get(lief.ELF.DynamicEntry.TAG.FLAGS)
|
|
164
|
+
bind_now = bind_now or any("BIND_NOW" in str(f) for f in entry.flags)
|
|
165
|
+
if binary.has(lief.ELF.DynamicEntry.TAG.FLAGS_1):
|
|
166
|
+
entry = binary.get(lief.ELF.DynamicEntry.TAG.FLAGS_1)
|
|
167
|
+
bind_now = bind_now or any("NOW" in str(f) for f in entry.flags)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
logger.debug(f"[native-hardening] RELRO/BIND_NOW inspection failed: {e}")
|
|
170
|
+
return "full" if bind_now else "partial"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _analyze_so(local_path: str, basename: str, zip_member: str) -> Optional[_SoFacts]:
|
|
174
|
+
try:
|
|
175
|
+
binary = lief.ELF.parse(local_path)
|
|
176
|
+
except Exception as e:
|
|
177
|
+
logger.debug(f"[native-hardening] LIEF failed to parse {basename}: {e}")
|
|
178
|
+
return None
|
|
179
|
+
if binary is None:
|
|
180
|
+
return None
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
has_nx = bool(binary.has_nx)
|
|
184
|
+
is_dyn = binary.header.file_type == lief.ELF.Header.FILE_TYPE.DYN
|
|
185
|
+
relro = _compute_relro(binary)
|
|
186
|
+
has_canary = bool(binary.has_symbol("__stack_chk_fail"))
|
|
187
|
+
# Only pay for the .text decode when it can still change the finding.
|
|
188
|
+
instrumentable: Optional[bool] = None
|
|
189
|
+
if not has_canary:
|
|
190
|
+
instrumentable = has_instrumentable_stack_code(binary)
|
|
191
|
+
symbol_names = [s.name for s in binary.dynamic_symbols]
|
|
192
|
+
rpath = None
|
|
193
|
+
runpath = None
|
|
194
|
+
if binary.has(lief.ELF.DynamicEntry.TAG.RPATH):
|
|
195
|
+
rpath = binary.get(lief.ELF.DynamicEntry.TAG.RPATH).rpath
|
|
196
|
+
if binary.has(lief.ELF.DynamicEntry.TAG.RUNPATH):
|
|
197
|
+
runpath = binary.get(lief.ELF.DynamicEntry.TAG.RUNPATH).runpath
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.debug(f"[native-hardening] fact extraction failed for {basename}: {e}")
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
return _SoFacts(
|
|
203
|
+
basename=basename,
|
|
204
|
+
zip_member=zip_member,
|
|
205
|
+
is_vendor=_is_vendor_lib(basename, symbol_names),
|
|
206
|
+
has_nx=has_nx,
|
|
207
|
+
is_dyn=is_dyn,
|
|
208
|
+
relro=relro,
|
|
209
|
+
has_canary=has_canary,
|
|
210
|
+
rpath=rpath,
|
|
211
|
+
runpath=runpath,
|
|
212
|
+
instrumentable=instrumentable,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _mk_finding(facts: _SoFacts, rule_id: str, name: str, severity: str, cwe: str,
|
|
217
|
+
description: str, recommendation: str,
|
|
218
|
+
location_symbol: Optional[str] = None) -> Dict[str, Any]:
|
|
219
|
+
f: Dict[str, Any] = {
|
|
220
|
+
"rule_id": rule_id,
|
|
221
|
+
"file_path": facts.zip_member,
|
|
222
|
+
"name": name,
|
|
223
|
+
"severity": "LOW" if facts.is_vendor else severity,
|
|
224
|
+
"details": {
|
|
225
|
+
"description": description,
|
|
226
|
+
"recommendation": recommendation,
|
|
227
|
+
"cwe": cwe,
|
|
228
|
+
"masvs": "MSTG-CODE-9",
|
|
229
|
+
},
|
|
230
|
+
"line": 1,
|
|
231
|
+
"engine": "native-hardening",
|
|
232
|
+
"native_lib": {
|
|
233
|
+
"basename": facts.basename,
|
|
234
|
+
"own_code": not facts.is_vendor,
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
# Console display only; SARIF output keeps using `line`.
|
|
238
|
+
if location_symbol:
|
|
239
|
+
f["location_symbol"] = location_symbol
|
|
240
|
+
return f
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _canary_evidence_note(facts: _SoFacts) -> str:
|
|
244
|
+
"""Explain why an absent stack-canary symbol is treated as a real finding."""
|
|
245
|
+
if facts.instrumentable is True:
|
|
246
|
+
return (
|
|
247
|
+
"This library does contain code the protector would have instrumented "
|
|
248
|
+
"(it materialises the address of a stack slot into a register - a local "
|
|
249
|
+
"array or an escaping local), so the absent symbol really does mean the "
|
|
250
|
+
"flag was off, not that there was nothing to protect. "
|
|
251
|
+
)
|
|
252
|
+
return (
|
|
253
|
+
"NOTE: this architecture has no instrumentable-code decoder in this "
|
|
254
|
+
"scanner, so the absent symbol has not been corroborated - verify the "
|
|
255
|
+
"build flags before acting on it. "
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _facts_to_findings(facts: _SoFacts) -> List[Dict[str, Any]]:
|
|
260
|
+
out: List[Dict[str, Any]] = []
|
|
261
|
+
tag = "[Third-party/bundled library] " if facts.is_vendor else ""
|
|
262
|
+
scope_note = (
|
|
263
|
+
"This is a well-known bundled/third-party native library, not code the app's own "
|
|
264
|
+
"developer built - not directly actionable by them (any fix belongs to the "
|
|
265
|
+
"upstream vendor); shown for completeness, not at the same urgency as a gap in "
|
|
266
|
+
"the app's own native code."
|
|
267
|
+
if facts.is_vendor else
|
|
268
|
+
"This is one of the app's own native libraries (not a recognized bundled/"
|
|
269
|
+
"third-party SDK)."
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
if not facts.has_nx:
|
|
273
|
+
out.append(_mk_finding(
|
|
274
|
+
facts, "AND-BIN-001", tag + "Native Library Missing NX (Executable Stack)",
|
|
275
|
+
severity="MEDIUM", cwe="CWE-119",
|
|
276
|
+
description=(
|
|
277
|
+
f"{facts.basename} does not have the NX bit enabled (its PT_GNU_STACK "
|
|
278
|
+
f"segment is marked executable, or is absent) - the stack is executable, "
|
|
279
|
+
f"making injected-shellcode exploitation of any memory-corruption bug in "
|
|
280
|
+
f"this library meaningfully easier. {scope_note}"
|
|
281
|
+
),
|
|
282
|
+
recommendation=(
|
|
283
|
+
"Rebuild the native library without `-z execstack` - the modern Android "
|
|
284
|
+
"NDK toolchain disables this by default, so this usually indicates a "
|
|
285
|
+
"custom/legacy linker flag override."
|
|
286
|
+
),
|
|
287
|
+
location_symbol="PT_GNU_STACK segment (NX flag)",
|
|
288
|
+
))
|
|
289
|
+
|
|
290
|
+
if not facts.is_dyn:
|
|
291
|
+
out.append(_mk_finding(
|
|
292
|
+
facts, "AND-BIN-002", tag + "Native Library Not Built as Position-Independent (No PIE)",
|
|
293
|
+
severity="MEDIUM", cwe="CWE-693",
|
|
294
|
+
description=(
|
|
295
|
+
f"{facts.basename} is not a Dynamic Shared Object (ET_DYN) - unusual for "
|
|
296
|
+
f"a .so file, and it defeats ASLR for this library's mapped address "
|
|
297
|
+
f"range, making return-to-libc/ROP-style exploitation of any other bug in "
|
|
298
|
+
f"it meaningfully more reliable. {scope_note}"
|
|
299
|
+
),
|
|
300
|
+
recommendation=(
|
|
301
|
+
"Rebuild with `-fPIC`/`-shared` (the standard, default way to build any "
|
|
302
|
+
"Android .so) - this typically indicates a broken or hand-rolled build "
|
|
303
|
+
"step, not a deliberate choice."
|
|
304
|
+
),
|
|
305
|
+
location_symbol="ELF header (e_type != ET_DYN)",
|
|
306
|
+
))
|
|
307
|
+
|
|
308
|
+
if facts.relro != "full":
|
|
309
|
+
is_none = facts.relro == "none"
|
|
310
|
+
label = "No RELRO" if is_none else "Partial RELRO (GOT Not Fully Read-Only)"
|
|
311
|
+
detail = (
|
|
312
|
+
"its Global Offset Table (GOT) is fully writable at runtime"
|
|
313
|
+
if is_none else
|
|
314
|
+
"its GOT is marked read-only after relocation (PT_GNU_RELRO present) but "
|
|
315
|
+
"lazy binding is still active (no DT_FLAGS/DT_FLAGS_1 BIND_NOW), leaving the "
|
|
316
|
+
".got.plt section writable until first use"
|
|
317
|
+
)
|
|
318
|
+
out.append(_mk_finding(
|
|
319
|
+
facts, "AND-BIN-003", tag + label,
|
|
320
|
+
severity="MEDIUM" if is_none else "LOW", cwe="CWE-732",
|
|
321
|
+
description=(
|
|
322
|
+
f"{facts.basename} does not have full RELRO protection - {detail}, making "
|
|
323
|
+
f"GOT-overwrite exploitation of any other memory-corruption bug in this "
|
|
324
|
+
f"library easier. {scope_note}"
|
|
325
|
+
),
|
|
326
|
+
recommendation=(
|
|
327
|
+
"Relink with `-Wl,-z,relro -Wl,-z,now` (Full RELRO) - standard practice, "
|
|
328
|
+
"no meaningful runtime cost for a mobile app."
|
|
329
|
+
),
|
|
330
|
+
location_symbol="PT_GNU_RELRO segment / DT_FLAGS(_1) BIND_NOW",
|
|
331
|
+
))
|
|
332
|
+
|
|
333
|
+
# Absent `__stack_chk_fail` only proves the protector was off if there was code to instrument.
|
|
334
|
+
if not facts.has_canary and facts.instrumentable is not False:
|
|
335
|
+
out.append(_mk_finding(
|
|
336
|
+
facts, "AND-BIN-004", tag + "Native Library Missing Stack Canary",
|
|
337
|
+
severity="MEDIUM", cwe="CWE-121",
|
|
338
|
+
description=(
|
|
339
|
+
f"{facts.basename} was not built with a stack protector (no "
|
|
340
|
+
f"`__stack_chk_fail` symbol) - a stack buffer overflow in this library's "
|
|
341
|
+
f"own code cannot be detected before a corrupted return address is used. "
|
|
342
|
+
f"{_canary_evidence_note(facts)}{scope_note}"
|
|
343
|
+
),
|
|
344
|
+
recommendation=(
|
|
345
|
+
"Rebuild with `-fstack-protector-strong` (or `-all`) - on by default in a "
|
|
346
|
+
"modern NDK, so this usually means a custom/legacy toolchain or an "
|
|
347
|
+
"explicit override."
|
|
348
|
+
),
|
|
349
|
+
location_symbol="symbol table (__stack_chk_fail absent)",
|
|
350
|
+
))
|
|
351
|
+
|
|
352
|
+
weak_path = facts.rpath or facts.runpath
|
|
353
|
+
if weak_path:
|
|
354
|
+
which = "RPATH" if facts.rpath else "RUNPATH"
|
|
355
|
+
is_weak = _is_weak_search_path(weak_path)
|
|
356
|
+
extra = (
|
|
357
|
+
", and this entry is relative and/or points at a location an on-device "
|
|
358
|
+
"attacker could plausibly write to (app-private external storage, a "
|
|
359
|
+
"world-writable tmp dir, ...) - a library planted there would be loaded "
|
|
360
|
+
"instead of the real one (uncontrolled search path / library injection)."
|
|
361
|
+
if is_weak else
|
|
362
|
+
", an unusual thing for a well-hardened release Android library to ship at "
|
|
363
|
+
"all (Android apps normally rely on the default linker search order)."
|
|
364
|
+
)
|
|
365
|
+
out.append(_mk_finding(
|
|
366
|
+
facts, "AND-BIN-005", tag + f"{which} Set in Native Library",
|
|
367
|
+
severity="MEDIUM" if is_weak else "LOW", cwe="CWE-427",
|
|
368
|
+
description=(
|
|
369
|
+
f"{facts.basename} embeds a {which} value ('{weak_path}') - the dynamic "
|
|
370
|
+
f"linker will search this path for dependent libraries at load time{extra} "
|
|
371
|
+
f"{scope_note}"
|
|
372
|
+
),
|
|
373
|
+
recommendation=(
|
|
374
|
+
"Remove the RPATH/RUNPATH from the link step - check for a stray "
|
|
375
|
+
"`-rpath`/`-Wl,-rpath` flag left over from a local/dev build config."
|
|
376
|
+
),
|
|
377
|
+
location_symbol=f"DT_{which} entry: {weak_path}",
|
|
378
|
+
))
|
|
379
|
+
|
|
380
|
+
return out
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
# Canonical rule catalog, SARIF "tool.driver.rules" style.
|
|
384
|
+
_RULE_CATALOG: Dict[str, Dict[str, Any]] = {
|
|
385
|
+
"AND-BIN-001": {
|
|
386
|
+
"id": "AND-BIN-001", "name": "Native Library Missing NX (Executable Stack)",
|
|
387
|
+
"severity": "MEDIUM", "masvs": "MSTG-CODE-9",
|
|
388
|
+
"details": {
|
|
389
|
+
"description": "A native (.so) library ships without the NX bit set, leaving its stack executable.",
|
|
390
|
+
"recommendation": "Rebuild without `-z execstack`; modern NDK toolchains disable this by default.",
|
|
391
|
+
"cwe": "CWE-119", "masvs": "MSTG-CODE-9",
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
"AND-BIN-002": {
|
|
395
|
+
"id": "AND-BIN-002", "name": "Native Library Not Built as Position-Independent (No PIE)",
|
|
396
|
+
"severity": "MEDIUM", "masvs": "MSTG-CODE-9",
|
|
397
|
+
"details": {
|
|
398
|
+
"description": "A native (.so) library is not a Dynamic Shared Object (ET_DYN), defeating ASLR for its mapped range.",
|
|
399
|
+
"recommendation": "Rebuild with `-fPIC`/`-shared`, the standard way to build any Android .so.",
|
|
400
|
+
"cwe": "CWE-693", "masvs": "MSTG-CODE-9",
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
"AND-BIN-003": {
|
|
404
|
+
"id": "AND-BIN-003", "name": "Native Library Missing/Partial RELRO",
|
|
405
|
+
"severity": "MEDIUM", "masvs": "MSTG-CODE-9",
|
|
406
|
+
"details": {
|
|
407
|
+
"description": "A native (.so) library's GOT is not fully read-only at runtime (no/partial RELRO), easing GOT-overwrite exploitation.",
|
|
408
|
+
"recommendation": "Relink with `-Wl,-z,relro -Wl,-z,now` for Full RELRO.",
|
|
409
|
+
"cwe": "CWE-732", "masvs": "MSTG-CODE-9",
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
"AND-BIN-004": {
|
|
413
|
+
"id": "AND-BIN-004", "name": "Native Library Missing Stack Canary",
|
|
414
|
+
"severity": "MEDIUM", "masvs": "MSTG-CODE-9",
|
|
415
|
+
"details": {
|
|
416
|
+
"description": "A native (.so) library was not built with a stack protector (no `__stack_chk_fail`), so stack buffer overflows go undetected.",
|
|
417
|
+
"recommendation": "Rebuild with `-fstack-protector-strong` (or `-all`).",
|
|
418
|
+
"cwe": "CWE-121", "masvs": "MSTG-CODE-9",
|
|
419
|
+
},
|
|
420
|
+
},
|
|
421
|
+
"AND-BIN-005": {
|
|
422
|
+
"id": "AND-BIN-005", "name": "RPATH/RUNPATH Set in Native Library",
|
|
423
|
+
"severity": "MEDIUM", "masvs": "MSTG-CODE-9",
|
|
424
|
+
"details": {
|
|
425
|
+
"description": "A native (.so) library embeds an RPATH/RUNPATH, an uncontrolled search path for the dynamic linker.",
|
|
426
|
+
"recommendation": "Remove the RPATH/RUNPATH from the link step.",
|
|
427
|
+
"cwe": "CWE-427", "masvs": "MSTG-CODE-9",
|
|
428
|
+
},
|
|
429
|
+
},
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def scan(apk_path: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], Dict[str, Any]]:
|
|
434
|
+
"""Run the ELF hardening checks over an APK/AAB. Returns (findings, rule_defs, stats). Never raises."""
|
|
435
|
+
stats: Dict[str, Any] = {
|
|
436
|
+
"lief_available": LIEF_AVAILABLE,
|
|
437
|
+
"arch_analyzed": None,
|
|
438
|
+
"libs_found_total": 0,
|
|
439
|
+
"libs_analyzed": 0,
|
|
440
|
+
"own_libs_analyzed": 0,
|
|
441
|
+
"vendor_libs_analyzed": 0,
|
|
442
|
+
"parse_failures": 0,
|
|
443
|
+
"findings": 0,
|
|
444
|
+
}
|
|
445
|
+
findings: List[Dict[str, Any]] = []
|
|
446
|
+
|
|
447
|
+
if not LIEF_AVAILABLE:
|
|
448
|
+
logger.debug("[native-hardening] lief not importable - skipping native hardening checks")
|
|
449
|
+
return findings, [], stats
|
|
450
|
+
|
|
451
|
+
used_rule_ids: set = set()
|
|
452
|
+
|
|
453
|
+
try:
|
|
454
|
+
with zipfile.ZipFile(apk_path, "r") as zf:
|
|
455
|
+
namelist = zf.namelist()
|
|
456
|
+
all_so = [n for n in namelist if n.startswith("lib/") and n.endswith(".so")]
|
|
457
|
+
stats["libs_found_total"] = len(all_so)
|
|
458
|
+
|
|
459
|
+
arch = _pick_arch(namelist)
|
|
460
|
+
if arch is None:
|
|
461
|
+
return findings, [], stats
|
|
462
|
+
|
|
463
|
+
stats["arch_analyzed"] = arch
|
|
464
|
+
prefix = f"lib/{arch}/"
|
|
465
|
+
arch_members: Dict[str, str] = {}
|
|
466
|
+
for n in all_so:
|
|
467
|
+
if n.startswith(prefix):
|
|
468
|
+
basename = n[len(prefix):]
|
|
469
|
+
if "/" in basename:
|
|
470
|
+
continue
|
|
471
|
+
arch_members.setdefault(basename, n)
|
|
472
|
+
|
|
473
|
+
if not arch_members:
|
|
474
|
+
return findings, [], stats
|
|
475
|
+
|
|
476
|
+
with tempfile.TemporaryDirectory(prefix="narvy-nh-") as tmpdir:
|
|
477
|
+
for basename in sorted(arch_members):
|
|
478
|
+
member = arch_members[basename]
|
|
479
|
+
local_path = _safe_extract(zf, member, tmpdir)
|
|
480
|
+
if local_path is None:
|
|
481
|
+
stats["parse_failures"] += 1
|
|
482
|
+
continue
|
|
483
|
+
facts = _analyze_so(local_path, basename, member)
|
|
484
|
+
if facts is None:
|
|
485
|
+
stats["parse_failures"] += 1
|
|
486
|
+
continue
|
|
487
|
+
stats["libs_analyzed"] += 1
|
|
488
|
+
if facts.is_vendor:
|
|
489
|
+
stats["vendor_libs_analyzed"] += 1
|
|
490
|
+
else:
|
|
491
|
+
stats["own_libs_analyzed"] += 1
|
|
492
|
+
lib_findings = _facts_to_findings(facts)
|
|
493
|
+
for f in lib_findings:
|
|
494
|
+
used_rule_ids.add(f["rule_id"])
|
|
495
|
+
findings.extend(lib_findings)
|
|
496
|
+
except zipfile.BadZipFile:
|
|
497
|
+
logger.error(f"[native-hardening] Invalid APK/AAB file: {apk_path}")
|
|
498
|
+
except Exception as e:
|
|
499
|
+
logger.error(f"[native-hardening] Unexpected error scanning {apk_path}: {e}")
|
|
500
|
+
|
|
501
|
+
rule_defs = [_RULE_CATALOG[rid] for rid in sorted(used_rule_ids)]
|
|
502
|
+
stats["findings"] = len(findings)
|
|
503
|
+
return findings, rule_defs, stats
|
narvy/reporter.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from typing import List, Dict, Any
|
|
3
|
+
|
|
4
|
+
from narvy import __version__
|
|
5
|
+
|
|
6
|
+
_FAIRPLAY_RULE_ID = "IOS-FAIRPLAY-ENCRYPTED"
|
|
7
|
+
_FAIRPLAY_COVERAGE_TEXT = (
|
|
8
|
+
"Partial coverage: this iOS binary is FairPlay-encrypted (App Store DRM, "
|
|
9
|
+
"cryptid=1), so its __TEXT code section is ciphertext and could not be read "
|
|
10
|
+
"statically. Info.plist, entitlements, URL schemes/ATS, load commands and "
|
|
11
|
+
"Objective-C metadata WERE analyzed. The app's own code and string literals "
|
|
12
|
+
"were NOT. Zero findings from the code/string rules on this run means 'not "
|
|
13
|
+
"visible', not 'clean' - do not treat this scan as a clean bill of health."
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def generate_sarif_report(findings: List[Dict[str, Any]], rules: List[Dict[str, Any]],
|
|
18
|
+
encrypted: bool = False,
|
|
19
|
+
encrypted_artifact_uri: str = None) -> Dict[str, Any]:
|
|
20
|
+
"""Generate a SARIF v2.1.0 report."""
|
|
21
|
+
_SARIF_PRECISION = {"LOW": "low", "MEDIUM": "medium", "HIGH": "high"}
|
|
22
|
+
|
|
23
|
+
# SARIF's standard per-result field; properties.severity keeps our own label.
|
|
24
|
+
_SARIF_LEVEL = {
|
|
25
|
+
"CRITICAL": "error", "HIGH": "error",
|
|
26
|
+
"MEDIUM": "warning",
|
|
27
|
+
"LOW": "note", "INFO": "note",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
results = []
|
|
31
|
+
for finding in findings:
|
|
32
|
+
confidence = finding.get('confidence', 'MEDIUM')
|
|
33
|
+
_sev = str(finding.get('severity', '') or '').upper()
|
|
34
|
+
results.append({
|
|
35
|
+
"ruleId": finding['rule_id'],
|
|
36
|
+
"level": _SARIF_LEVEL.get(_sev, "warning"),
|
|
37
|
+
"message": {
|
|
38
|
+
"text": finding['details']['description']
|
|
39
|
+
},
|
|
40
|
+
"locations": [{
|
|
41
|
+
"physicalLocation": {
|
|
42
|
+
"artifactLocation": {
|
|
43
|
+
"uri": finding['file_path']
|
|
44
|
+
},
|
|
45
|
+
"region": {
|
|
46
|
+
"startLine": finding.get('line', 1)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}],
|
|
50
|
+
"properties": {
|
|
51
|
+
"confidence": confidence,
|
|
52
|
+
"precision": _SARIF_PRECISION.get(confidence, "medium"),
|
|
53
|
+
"severity": _sev or "UNKNOWN",
|
|
54
|
+
**({"prngContext": finding['details']['prng_context'],
|
|
55
|
+
"prngContextEvidence": finding['details'].get('prng_context_evidence', '')}
|
|
56
|
+
if isinstance(finding.get('details'), dict)
|
|
57
|
+
and finding['details'].get('prng_context') else {}),
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
if encrypted:
|
|
62
|
+
_coverage_result: Dict[str, Any] = {
|
|
63
|
+
"ruleId": _FAIRPLAY_RULE_ID,
|
|
64
|
+
"level": "note",
|
|
65
|
+
"message": {"text": _FAIRPLAY_COVERAGE_TEXT},
|
|
66
|
+
"properties": {
|
|
67
|
+
"confidence": "HIGH",
|
|
68
|
+
"precision": "very-high",
|
|
69
|
+
"severity": "INFO",
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
if encrypted_artifact_uri:
|
|
73
|
+
_coverage_result["locations"] = [{
|
|
74
|
+
"physicalLocation": {
|
|
75
|
+
"artifactLocation": {"uri": encrypted_artifact_uri},
|
|
76
|
+
"region": {"startLine": 1},
|
|
77
|
+
}
|
|
78
|
+
}]
|
|
79
|
+
results.append(_coverage_result)
|
|
80
|
+
|
|
81
|
+
tool_rules = []
|
|
82
|
+
for rule in rules:
|
|
83
|
+
rule_confidence = rule.get('confidence', 'MEDIUM')
|
|
84
|
+
tool_rules.append({
|
|
85
|
+
"id": rule['id'],
|
|
86
|
+
"name": rule['name'],
|
|
87
|
+
"shortDescription": {
|
|
88
|
+
"text": rule['details']['description']
|
|
89
|
+
},
|
|
90
|
+
"fullDescription": {
|
|
91
|
+
"text": rule['details']['recommendation']
|
|
92
|
+
},
|
|
93
|
+
"help": {
|
|
94
|
+
"text": f"CWE: {rule['details'].get('cwe')}\nMASVS: {rule['details'].get('masvs')}"
|
|
95
|
+
},
|
|
96
|
+
"properties": {
|
|
97
|
+
"tags": ["security", "android", rule.get('masvs', '').lower()],
|
|
98
|
+
"precision": _SARIF_PRECISION.get(rule_confidence, "medium"),
|
|
99
|
+
"problem.severity": rule.get('severity', 'UNKNOWN').lower()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
if encrypted:
|
|
104
|
+
# GitHub's SARIF ingester resolves results[].ruleId against
|
|
105
|
+
# tool.driver.rules[].id, so the result above needs this descriptor.
|
|
106
|
+
tool_rules.append({
|
|
107
|
+
"id": _FAIRPLAY_RULE_ID,
|
|
108
|
+
"name": "FairPlayEncryptedBinaryPartialCoverage",
|
|
109
|
+
"shortDescription": {
|
|
110
|
+
"text": "Binary is FairPlay-encrypted - static analysis coverage is partial."
|
|
111
|
+
},
|
|
112
|
+
"fullDescription": {"text": _FAIRPLAY_COVERAGE_TEXT},
|
|
113
|
+
"help": {
|
|
114
|
+
"text": "Scan a decrypted binary (jailbroken-device dump) to analyze the "
|
|
115
|
+
"app's own code and string literals. This is a property of App "
|
|
116
|
+
"Store distribution, not a defect in the app."
|
|
117
|
+
},
|
|
118
|
+
"properties": {
|
|
119
|
+
"tags": ["security", "ios", "coverage"],
|
|
120
|
+
"precision": "very-high",
|
|
121
|
+
"problem.severity": "note",
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
run: Dict[str, Any] = {
|
|
126
|
+
"tool": {
|
|
127
|
+
"driver": {
|
|
128
|
+
"name": "Narvy CLI",
|
|
129
|
+
"version": __version__,
|
|
130
|
+
"informationUri": "https://narvy.io",
|
|
131
|
+
"rules": tool_rules
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"results": results
|
|
135
|
+
}
|
|
136
|
+
if encrypted:
|
|
137
|
+
run["invocations"] = [{
|
|
138
|
+
# The invocation object's only required property (SARIF 2.1.0).
|
|
139
|
+
"executionSuccessful": True,
|
|
140
|
+
"toolExecutionNotifications": [{
|
|
141
|
+
"level": "note",
|
|
142
|
+
"message": {"text": _FAIRPLAY_COVERAGE_TEXT},
|
|
143
|
+
}],
|
|
144
|
+
}]
|
|
145
|
+
|
|
146
|
+
report = {
|
|
147
|
+
"$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
|
|
148
|
+
"version": "2.1.0",
|
|
149
|
+
"runs": [run]
|
|
150
|
+
}
|
|
151
|
+
return report
|