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,374 @@
1
+ """SSRF defense for the web scanner: URL validation, IP pinning, and
2
+ redirect-aware safe fetching.
3
+ """
4
+
5
+ import ipaddress
6
+ import os
7
+ import socket
8
+ from typing import List, Optional, Set, Tuple
9
+ from urllib.parse import urlparse, urljoin
10
+
11
+ import requests
12
+ from requests.adapters import HTTPAdapter
13
+ from urllib3.poolmanager import PoolManager
14
+
15
+
16
+ def _allowlisted_hosts() -> Set[str]:
17
+ """Opt-in allowlist of host[:port] entries that bypass the IP blocklist."""
18
+ raw = os.environ.get('SSRF_GUARD_ALLOW_HOSTS', '')
19
+ return {h.strip().lower() for h in raw.split(',') if h.strip()}
20
+
21
+ try:
22
+ from loguru import logger
23
+ except Exception: # pragma: no cover - loguru always present in prod
24
+ import logging
25
+ logger = logging.getLogger("ssrf_guard")
26
+
27
+
28
+ # Blocked by name too: split-horizon DNS can point these at a benign IP.
29
+ METADATA_HOSTNAMES = {
30
+ 'localhost',
31
+ 'metadata.google.internal',
32
+ 'metadata',
33
+ 'instance-data',
34
+ 'instance-data.ec2.internal',
35
+ }
36
+
37
+ # IPv4 nets the stdlib does not flag but a scanner must never reach.
38
+ _BLOCKED_V4_NETS = (
39
+ ipaddress.ip_network('100.64.0.0/10'), # CGNAT / carrier-grade NAT range
40
+ ipaddress.ip_network('0.0.0.0/8'), # "this network"
41
+ ipaddress.ip_network('198.18.0.0/15'), # benchmarking
42
+ ipaddress.ip_network('192.0.0.0/24'), # IETF protocol assignments
43
+ ipaddress.ip_network('240.0.0.0/4'), # future-use / reserved
44
+ ipaddress.ip_network('224.0.0.0/4'), # multicast
45
+ )
46
+
47
+ # Mostly covered by the stdlib is_* properties; listed explicitly for clarity.
48
+ _BLOCKED_V6_NETS = (
49
+ ipaddress.ip_network('fc00::/7'), # unique-local (ULA)
50
+ ipaddress.ip_network('fe80::/10'), # link-local (also is_link_local)
51
+ )
52
+
53
+ MAX_URL_LEN = 2048
54
+ DEFAULT_MAX_REDIRECTS = 5
55
+
56
+ # Caps buffered body so a hostile target cannot OOM the process.
57
+ MAX_RESPONSE_BYTES = 25 * 1024 * 1024
58
+ _READ_CHUNK = 65536
59
+
60
+
61
+ class SSRFBlocked(Exception):
62
+ """Raised when a URL is rejected by the SSRF guard. The message is safe to show."""
63
+
64
+
65
+ class ResponseTooLarge(Exception):
66
+ """Raised when a target's response exceeds MAX_RESPONSE_BYTES."""
67
+
68
+
69
+ def _ip_is_blocked(ip_obj) -> bool:
70
+ # IPv4-mapped IPv6 (e.g. ::ffff:127.0.0.1) is unwrapped and re-checked.
71
+ if isinstance(ip_obj, ipaddress.IPv6Address) and ip_obj.ipv4_mapped:
72
+ ip_obj = ip_obj.ipv4_mapped
73
+
74
+ if (
75
+ ip_obj.is_private
76
+ or ip_obj.is_loopback
77
+ or ip_obj.is_link_local
78
+ or ip_obj.is_reserved
79
+ or ip_obj.is_multicast
80
+ or ip_obj.is_unspecified
81
+ ):
82
+ return True
83
+
84
+ if isinstance(ip_obj, ipaddress.IPv4Address):
85
+ # Cloud metadata range; already covered by is_link_local, kept explicit.
86
+ if str(ip_obj).startswith('169.254.'):
87
+ return True
88
+ if str(ip_obj) == '255.255.255.255':
89
+ return True
90
+ for net in _BLOCKED_V4_NETS:
91
+ if ip_obj in net:
92
+ return True
93
+ else:
94
+ for net in _BLOCKED_V6_NETS:
95
+ if ip_obj in net:
96
+ return True
97
+
98
+ return False
99
+
100
+
101
+ def _resolve_all_ips(hostname: str, port: int) -> List[Tuple[int, str]]:
102
+ results: List[Tuple[int, str]] = []
103
+ try:
104
+ infos = socket.getaddrinfo(hostname, port, family=socket.AF_UNSPEC,
105
+ type=socket.SOCK_STREAM)
106
+ except socket.gaierror as e:
107
+ raise SSRFBlocked(f"Could not resolve hostname: {hostname}") from e
108
+
109
+ for family, _stype, _proto, _canon, sockaddr in infos:
110
+ ip_str = sockaddr[0]
111
+ # Strip any IPv6 scope id (e.g. fe80::1%eth0).
112
+ if '%' in ip_str:
113
+ ip_str = ip_str.split('%', 1)[0]
114
+ results.append((family, ip_str))
115
+
116
+ if not results:
117
+ raise SSRFBlocked(f"Could not resolve hostname: {hostname}")
118
+ return results
119
+
120
+
121
+ def validate_url(url: str) -> Tuple[str, str, int]:
122
+ """Validate a URL, returning (validated_url, pinned_ip, port) or raising."""
123
+ if not url or not isinstance(url, str):
124
+ raise SSRFBlocked("Empty or invalid URL")
125
+ if len(url) > MAX_URL_LEN:
126
+ raise SSRFBlocked(f"URL too long (max {MAX_URL_LEN} characters)")
127
+
128
+ # urlparse() and .port raise on a malformed IPv6 bracket or bad port.
129
+ try:
130
+ parsed = urlparse(url)
131
+ scheme = (parsed.scheme or '').lower()
132
+ hostname = parsed.hostname
133
+ port_value = parsed.port
134
+ except ValueError as e:
135
+ raise SSRFBlocked(f"Invalid URL: {e}")
136
+
137
+ if scheme not in ('http', 'https'):
138
+ raise SSRFBlocked("Only HTTP and HTTPS URLs are allowed")
139
+
140
+ if not hostname:
141
+ raise SSRFBlocked("Invalid URL - no hostname found")
142
+ hostname = hostname.lower()
143
+
144
+ port = port_value or (443 if scheme == 'https' else 80)
145
+
146
+ _allow = _allowlisted_hosts()
147
+ if _allow and (hostname in _allow or f"{hostname}:{port}" in _allow):
148
+ try:
149
+ literal = ipaddress.ip_address(hostname)
150
+ return url, str(literal), port
151
+ except ValueError:
152
+ # Allowlisted by name, still resolved so there is an IP to pin.
153
+ try:
154
+ resolved = _resolve_all_ips(hostname, port)
155
+ return url, resolved[0][1], port
156
+ except SSRFBlocked:
157
+ raise
158
+
159
+ if hostname in METADATA_HOSTNAMES:
160
+ raise SSRFBlocked("Scanning internal/metadata addresses is not allowed")
161
+
162
+ try:
163
+ literal = ipaddress.ip_address(hostname)
164
+ if _ip_is_blocked(literal):
165
+ raise SSRFBlocked("Scanning internal/private/reserved IP addresses is not allowed")
166
+ return url, str(literal), port
167
+ except ValueError:
168
+ pass
169
+
170
+ resolved = _resolve_all_ips(hostname, port)
171
+
172
+ pinned_ip: Optional[str] = None
173
+ for _family, ip_str in resolved:
174
+ try:
175
+ ip_obj = ipaddress.ip_address(ip_str)
176
+ except ValueError:
177
+ raise SSRFBlocked("Resolved to an unparseable address")
178
+ if _ip_is_blocked(ip_obj):
179
+ # Anti-rebinding: one dangerous record rejects the whole host.
180
+ raise SSRFBlocked(
181
+ "Host resolves to an internal/private/reserved address - blocked"
182
+ )
183
+ if pinned_ip is None:
184
+ pinned_ip = ip_str
185
+
186
+ if pinned_ip is None:
187
+ raise SSRFBlocked(f"Could not resolve hostname: {hostname}")
188
+
189
+ return url, pinned_ip, port
190
+
191
+
192
+ class SSRFSafeAdapter(HTTPAdapter):
193
+ """HTTPAdapter that pins the TCP connection to a pre-validated IP, keeping the
194
+ hostname for Host header/SNI so it cannot be re-resolved between validate and connect."""
195
+
196
+ def __init__(self, pinned_host: str, pinned_ip: str, *args, **kwargs):
197
+ self._pinned_host = pinned_host.lower()
198
+ self._pinned_ip = pinned_ip
199
+ super().__init__(*args, **kwargs)
200
+
201
+ def init_poolmanager(self, connections, maxsize, block=False, **pool_kwargs):
202
+ # Pinning happens in send() via a scoped create_connection swap.
203
+ self.poolmanager = PoolManager(
204
+ num_pools=connections, maxsize=maxsize, block=block, **pool_kwargs
205
+ )
206
+
207
+ def _make_pinned_create_connection(self, orig_create_connection):
208
+ pinned_host = self._pinned_host
209
+ pinned_ip = self._pinned_ip
210
+
211
+ def _pinned_create_connection(address, *a, **kw):
212
+ host = address[0] if address else None
213
+ if host and host.lower() == pinned_host:
214
+ address = (pinned_ip,) + tuple(address[1:])
215
+ return orig_create_connection(address, *a, **kw)
216
+
217
+ return _pinned_create_connection
218
+
219
+ def send(self, request, *args, **kwargs):
220
+ import urllib3.util.connection as _u3c
221
+ import urllib3.connection as _u3conn
222
+ _orig_util = _u3c.create_connection
223
+ pinned = self._make_pinned_create_connection(_orig_util)
224
+ _orig_conn = getattr(_u3conn, 'create_connection', None)
225
+ _u3c.create_connection = pinned
226
+ if _orig_conn is not None:
227
+ _u3conn.create_connection = pinned
228
+ try:
229
+ return super().send(request, *args, **kwargs)
230
+ finally:
231
+ _u3c.create_connection = _orig_util
232
+ if _orig_conn is not None:
233
+ _u3conn.create_connection = _orig_conn
234
+
235
+
236
+ def _build_pinned_session(
237
+ validated_url: str,
238
+ pinned_ip: str,
239
+ base_session: Optional[requests.Session] = None,
240
+ user_agent: Optional[str] = None,
241
+ ) -> requests.Session:
242
+ host = urlparse(validated_url).hostname.lower()
243
+ if base_session is not None:
244
+ sess = base_session
245
+ else:
246
+ sess = requests.Session()
247
+ sess.verify = False
248
+ if user_agent:
249
+ sess.headers.setdefault('User-Agent', user_agent)
250
+
251
+ adapter = SSRFSafeAdapter(pinned_host=host, pinned_ip=pinned_ip)
252
+ sess.mount('http://', adapter)
253
+ sess.mount('https://', adapter)
254
+ return sess
255
+
256
+
257
+ def _materialize_capped(resp: requests.Response, url: str) -> None:
258
+ """Buffer a streamed response body into ``resp``, capped at MAX_RESPONSE_BYTES."""
259
+ declared = resp.headers.get('Content-Length')
260
+ if declared is not None:
261
+ try:
262
+ if int(declared) > MAX_RESPONSE_BYTES:
263
+ resp.close()
264
+ raise ResponseTooLarge(
265
+ f"{url}: declared Content-Length {declared} exceeds the "
266
+ f"{MAX_RESPONSE_BYTES} byte cap")
267
+ except ValueError:
268
+ pass
269
+
270
+ buf = bytearray()
271
+ try:
272
+ for chunk in resp.iter_content(chunk_size=_READ_CHUNK):
273
+ if not chunk:
274
+ continue
275
+ buf += chunk
276
+ if len(buf) > MAX_RESPONSE_BYTES:
277
+ resp.close()
278
+ raise ResponseTooLarge(
279
+ f"{url}: response body exceeded the {MAX_RESPONSE_BYTES} "
280
+ f"byte cap (streaming)")
281
+ finally:
282
+ resp.close()
283
+
284
+ # The two attributes `requests` sets on a cached body, so .content/.text/.json() work.
285
+ resp._content = bytes(buf)
286
+ resp._content_consumed = True
287
+
288
+
289
+ def safe_get(
290
+ url: str,
291
+ *,
292
+ timeout: int = 10,
293
+ headers: Optional[dict] = None,
294
+ session: Optional[requests.Session] = None,
295
+ max_redirects: int = DEFAULT_MAX_REDIRECTS,
296
+ verify: bool = False,
297
+ user_agent: Optional[str] = None,
298
+ **kwargs,
299
+ ) -> requests.Response:
300
+ """SSRF-safe GET: every redirect hop is re-validated and re-pinned. Cookies and
301
+ headers from ``session`` are preserved across hops."""
302
+ kwargs.pop('allow_redirects', None)
303
+
304
+ current_url = url
305
+ # Base session carries cookies; each hop gets a fresh pinned adapter (host/IP may differ).
306
+ base = session if session is not None else None
307
+ if base is None:
308
+ base = requests.Session()
309
+ base.verify = verify
310
+ if user_agent:
311
+ base.headers.setdefault('User-Agent', user_agent)
312
+
313
+ last_resp: Optional[requests.Response] = None
314
+ for hop in range(max_redirects + 1):
315
+ validated_url, pinned_ip, _port = validate_url(current_url)
316
+ sess = _build_pinned_session(validated_url, pinned_ip, base_session=base)
317
+
318
+ resp = sess.get(
319
+ validated_url,
320
+ timeout=timeout,
321
+ headers=headers,
322
+ allow_redirects=False,
323
+ verify=verify,
324
+ stream=True,
325
+ **kwargs,
326
+ )
327
+ last_resp = resp
328
+
329
+ if resp.is_redirect or resp.status_code in (301, 302, 303, 307, 308):
330
+ location = resp.headers.get('Location')
331
+ if not location:
332
+ _materialize_capped(resp, validated_url)
333
+ return resp
334
+ # A hop's body is closed unread, so it cannot smuggle a huge body past the cap.
335
+ next_url = urljoin(validated_url, location)
336
+ logger.debug(f"[ssrf_guard] redirect hop {hop}: {validated_url} -> {next_url}")
337
+ current_url = next_url
338
+ resp.close()
339
+ continue
340
+
341
+ _materialize_capped(resp, validated_url)
342
+ return resp
343
+
344
+ logger.warning(f"[ssrf_guard] max redirects ({max_redirects}) exceeded for {url}")
345
+ if last_resp is not None:
346
+ _materialize_capped(last_resp, current_url)
347
+ return last_resp
348
+
349
+
350
+ def safe_session(
351
+ url: str,
352
+ *,
353
+ base_session: Optional[requests.Session] = None,
354
+ user_agent: Optional[str] = None,
355
+ verify: bool = False,
356
+ ) -> Tuple[requests.Session, str]:
357
+ """Return (session, validated_url) pinned to ``url``'s IP, for one request.
358
+ Callers that follow redirects must use safe_get, which re-validates each hop."""
359
+ validated_url, pinned_ip, _port = validate_url(url)
360
+ sess = _build_pinned_session(
361
+ validated_url, pinned_ip, base_session=base_session, user_agent=user_agent
362
+ )
363
+ sess.verify = verify
364
+ return sess, validated_url
365
+
366
+
367
+ def is_safe_url(url: str) -> bool:
368
+ try:
369
+ validate_url(url)
370
+ return True
371
+ except SSRFBlocked:
372
+ return False
373
+ except Exception:
374
+ return False
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: narvy-cli
3
+ Version: 1.0.0
4
+ Summary: Free, local SAST scanner for Android, iOS, and polyglot web/backend source - plus passive web, host-audit, and cloud-posture scanning.
5
+ Author: Narvy
6
+ License: Apache-2.0
7
+ Project-URL: Homepage, https://narvy.io
8
+ Classifier: Development Status :: 5 - Production/Stable
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: Apache Software License
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.10
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Topic :: Security
18
+ Classifier: Topic :: Software Development :: Quality Assurance
19
+ Requires-Python: >=3.10
20
+ Description-Content-Type: text/markdown
21
+ License-File: LICENSE
22
+ Requires-Dist: pyaxmlparser
23
+ Requires-Dist: pyyaml
24
+ Requires-Dist: rich
25
+ Requires-Dist: requests
26
+ Requires-Dist: semgrep==1.152.0
27
+ Requires-Dist: lief
28
+ Provides-Extra: ios-full
29
+ Requires-Dist: icdump; extra == "ios-full"
30
+ Provides-Extra: cloud
31
+ Requires-Dist: boto3; extra == "cloud"
32
+ Dynamic: license-file
33
+
34
+ # Narvy CLI
35
+
36
+ [![PyPI version](https://badge.fury.io/py/narvy-cli.svg)](https://badge.fury.io/py/narvy-cli)
37
+ [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
38
+
39
+ A free, local static application security testing (SAST) scanner. It runs
40
+ entirely on your machine, needs no account, and finds real, high-signal
41
+ vulnerabilities - hardcoded secrets, insecure configuration, weak
42
+ cryptography, SSRF, injection, and more - in:
43
+
44
+ - **Android**: `.apk` / `.aab`, split-APK bundles (`.apkm` / `.xapk` /
45
+ `.apks`), or a raw Gradle/Kotlin source checkout
46
+ - **iOS**: an unencrypted `.ipa`, or a raw Xcode/Swift source checkout
47
+ - **Web/backend source**: JavaScript/TypeScript, Python, PHP, Go, Ruby,
48
+ Rust, Java/Kotlin, C#/.NET
49
+
50
+ It also ships three standalone scanners that need nothing but network
51
+ access or your own existing credentials:
52
+
53
+ - `web-scan` - passive Nuclei-based scan of a live URL
54
+ - `host-audit` - agentless host audit over your own SSH access (Narvy's own
55
+ host checks plus Lynis (GPL-3.0) as the hardening baseline)
56
+ - `cloud-scan` - AWS account posture check using your own ambient AWS
57
+ credentials
58
+
59
+ Nothing is uploaded anywhere unless you explicitly pass `--upload`.
60
+
61
+ ## Install
62
+
63
+ ```bash
64
+ pip install narvy-cli
65
+ ```
66
+
67
+ Requires a Java 11+ JRE on your `PATH` for Android scanning (jadx itself is
68
+ auto-downloaded on first scan into `~/.narvy/tools`). Everything else
69
+ is pure Python plus a couple of optional extras:
70
+
71
+ ```bash
72
+ pip install 'narvy-cli[ios-full]' # full Obj-C introspection for iOS binary scans
73
+ pip install 'narvy-cli[cloud]' # boto3, needed for cloud-scan
74
+ ```
75
+
76
+ ## Usage
77
+
78
+ ```bash
79
+ # Android APK/AAB, split bundle, or a Gradle/Kotlin source checkout
80
+ narvy scan app-release.apk
81
+ narvy scan app.apkm
82
+ narvy scan ./my-android-app/
83
+
84
+ # iOS: unencrypted IPA, or a Swift/Xcode source checkout
85
+ narvy scan MyApp.ipa
86
+ narvy scan ./my-ios-app/
87
+
88
+ # Web/backend source (Node, Python, PHP, Go, Ruby, Rust, Java/Kotlin, C#/.NET)
89
+ narvy scan ./my-api-repo/
90
+
91
+ # SARIF output, for CI or GitHub/GitLab code scanning
92
+ narvy scan app-release.apk --output sarif --file results.sarif
93
+
94
+ # Passive web scan of a live URL - no active injection payloads, ever
95
+ narvy web-scan https://example.com
96
+
97
+ # Agentless host hardening audit over your own SSH access
98
+ narvy host-audit example.com --user deploy
99
+
100
+ # AWS account posture check using your own aws configure / env vars / IAM role
101
+ narvy cloud-scan
102
+
103
+ # List recent scans on your Narvy account (requires `narvy login`)
104
+ narvy scans
105
+
106
+ # Scan + upload every .apk/.aab/.apkm/.xapk/.ipa found in a folder, in one command
107
+ narvy upload-all ./my-apps-folder/
108
+ ```
109
+
110
+ Run `narvy doctor` before your first scan to check your Java/jadx/iOS
111
+ tooling setup, and `narvy <command> --help` for the full flag list of
112
+ any subcommand.
113
+
114
+ ### Very large Android apps
115
+
116
+ Decompiling is memory-hungry, and file size is a poor predictor of how much
117
+ you need. Before decompiling, the CLI reads the DEX headers to estimate the
118
+ Java heap the app needs and checks it against `--max-mem` and your
119
+ machine's free memory, so an app that won't fit fails in about a second
120
+ instead of after several minutes of doomed work. The estimate is a
121
+ heuristic, not a hard limit - `--force` runs the decompile regardless.
122
+
123
+ ### Optional Narvy Cloud scan
124
+
125
+ `narvy login` links the CLI to a Narvy account. Add `--upload`
126
+ to a scan to also submit it for a deeper cloud pass (full taint
127
+ analysis, compliance mapping, dashboard history) - not required for local
128
+ scanning, and not on by default. `narvy upload-all <directory>` does
129
+ the same for every binary target found in a folder in one command.
130
+ `narvy scans` lists recent scans on your account from the terminal.
131
+
132
+ ## Known limitations
133
+
134
+ - Encrypted iOS binaries (the normal App Store distribution state) are
135
+ detected but not decrypted; only the local, static checks that don't need
136
+ the decrypted binary run.
137
+ - Web-source coverage depth isn't uniform across all eight languages - Rust
138
+ and C#/.NET have a smaller rule set than the others, and the CLI says so
139
+ in its own output when it applies.
140
+ - Only one architecture's native libraries are checked in a multi-ABI
141
+ APK/split bundle.
142
+
143
+ See [SETUP.md](SETUP.md) for detailed install/troubleshooting steps and the
144
+ exact commands this README's examples were run against.
145
+
146
+ ## License
147
+
148
+ Apache License 2.0 - see [LICENSE](LICENSE).
149
+
150
+ Rule packs under `narvy/rules/` are authored by Narvy
151
+ (Apache 2.0, same as the rest of this package) and contain no third-party
152
+ rule text.
153
+ `host-audit` runs Narvy's own host checks (secrets exposed on disk,
154
+ unauthenticated data services, cloud metadata exposure, container/daemon
155
+ posture, exposed VCS/artifacts in web roots) and, as the hardening baseline,
156
+ shells out to [Lynis](https://cisofy.com/lynis/) (GPL-3.0), auto-fetched at
157
+ run time over your SSH access, run arm's-length, never bundled. Lynis is
158
+ attributed as the engine in the audit output; its controls are not presented
159
+ as Narvy's own.
160
+
161
+ ## Contributing
162
+
163
+ Issues and pull requests welcome, especially new or improved detection
164
+ rules. Rule packs live under `narvy/rules/`: Android/iOS
165
+ (`rules/android`, `rules/ios`) and web/backend (`rules/web`, Semgrep-based).
@@ -0,0 +1,93 @@
1
+ narvy/__init__.py,sha256=IQxw1-Fgen70bKkXPsnhdnJKTnWRXaE_AwP5DbpyNvY,96
2
+ narvy/android_rule_context.py,sha256=6jzMyT4D5NpLcys32PLxqHClNKqvb7sCNCVA3N51P7U,7811
3
+ narvy/apk_memory_preflight.py,sha256=AIQ81rW2C0hXAqhYbTDrEH3lKj7fSF6ZXAA_iQl3_8A,10278
4
+ narvy/auth.py,sha256=to9_Luc6TgyQnZa5RcHELHQeaSTiY-Y2coX6WVe7Cm8,1160
5
+ narvy/comment_filter.py,sha256=absOixsgQrvMI1BzradWZQ6iS90HxWsIhE5P1tNjj3s,3918
6
+ narvy/crypto_taint_lite.py,sha256=aac8sM_o91gxVjEPlaQOe6GK0aSqM358Fh58BKKP7yg,3337
7
+ narvy/decompiler.py,sha256=cwXyikAkjNe5NNcymqRGFJe45XIiirP3PJfkqK95ljY,13198
8
+ narvy/doctor.py,sha256=IZ6wDs2R95BYKyY8kxgKyfFnFQ0hX6OAJqXD7sq2TtE,9869
9
+ narvy/main.py,sha256=6yJnOk6x35qxQSblRo88SDcFjHRAth5NmXo0_HJC-sw,97720
10
+ narvy/native_hardening.py,sha256=sQaSEFgnhHQ9ES2vWE3XDH74O5HHvu5qZSWBfykgvLw,20507
11
+ narvy/reporter.py,sha256=MV9CE6Otb9sGmUQxFWcIGjCWRtvh6I2tCvlwdevFwEs,5739
12
+ narvy/rule_engine.py,sha256=aKc0K_Xj0bGtXVaQAQI0m2E8gNl2xYHNAXfp1OEZX1U,2365
13
+ narvy/scope_config.py,sha256=Y5rHbNSh81TKNuFTxGlW-pX6Ov4xsBFsmW5JBFPHzJo,6862
14
+ narvy/semgrep_engine.py,sha256=PpWXSwcSZmZa6eEkGhd4ViKAbM8DZI5evm7Oc7dFirc,8564
15
+ narvy/stack_protector_evidence.py,sha256=9MgTkAqp1twnN-qJFBgYa4xg3WR5C1RSjARg-nF1DLc,3740
16
+ narvy/third_party_filter.py,sha256=Nvb5Mdx5FTGvCb2BfYoTxHXf6fOlwJtyvjMn1XsK6j8,7987
17
+ narvy/uploader.py,sha256=L7x3cn5p9Lu7VXqlZRtjJnKxiO_wLQHCmL-rFU_CuNo,2860
18
+ narvy/weak_prng_context.py,sha256=eYLiNw2p4ua8rbms3QhK5f8bifPyVUQ77Aq4XHm-woc,10080
19
+ narvy/android/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
20
+ narvy/android/source_analyzer.py,sha256=0roHyMfVViZDcmFI-73aVRYoYa9LOwEyErjzXkqSxXQ,10092
21
+ narvy/android/split_bundle.py,sha256=L4Y15OUHxeePX1AwlZQOb6cNeZhtn6Fkci0--qGlQVU,18881
22
+ narvy/ci_templates/bitbucket.yml,sha256=uYCvi1QH4p4U3B9XMGljf9rRCrGOUdTEcNr9mWHmPiY,978
23
+ narvy/ci_templates/github.yml,sha256=cWgnxeeXEJhkLtPgTbbDJZ30LGmQ4X3rlLY63SU-qyU,2002
24
+ narvy/ci_templates/gitlab.yml,sha256=BWBNu_EsAZRVK1lkbEoReUvqwRLsYbrXFETNGGS18VI,1234
25
+ narvy/cloud/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
26
+ narvy/cloud/aws_scan.py,sha256=DgUEwB6pZGf7LMX8P0Lv8CNMGNmeglfsZijms3tADiQ,53110
27
+ narvy/host/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
28
+ narvy/host/audit.py,sha256=6-wsjqDaOpJx9RZMJh_O7THWYnYnMUhaf7gqGE9TABs,5453
29
+ narvy/host/host_knowledge.py,sha256=pyw6iy7jSdRWow508IrPKmiRM3A744JG6lanLvZl0L0,52391
30
+ narvy/host/lynis_bootstrap.py,sha256=G2AqcPklBwScbNhCGBC7hUoOdXMtf3CD2yrwZxDes7E,15594
31
+ narvy/host/lynis_parser.py,sha256=jEK5UwyAqXnQL-cKvErZ-ho0NWBwL2V840HgzZNfCO0,12669
32
+ narvy/host/narvy_checks.py,sha256=TGuErYdgBsNgPY9XAfKaTK6GMBrJnU1A2vbROmbm55s,31179
33
+ narvy/host/report.py,sha256=hhSPpBrE4SP1xukg3ExyQ7Op6N_NAb_qwIBLZ0RT6vg,2397
34
+ narvy/host/ssh_exec.py,sha256=nh7xjMQcnJvk-i9mk8m-9s31Wjzp4CsHRQhnL4-aqLY,7517
35
+ narvy/ios/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
36
+ narvy/ios/binary_analyzer.py,sha256=NnPjHXC_7GJ4IHFdlCYH6BVvGALlLaY_bhrlKRsOULQ,56326
37
+ narvy/ios/plist_checks.py,sha256=XghxO8Y1YsbWpoEqf8M_p4i5mD-ZERep15PEaqx8Hpw,11693
38
+ narvy/ios/source_analyzer.py,sha256=xCIKzXOICkanqdQlHdibdRXqTWXWbJZhrgoRrKiP4Os,11086
39
+ narvy/ios/third_party_filter.py,sha256=wl-bLev2nfuTd30Ga0qU8tm-COEB4oftWk1y20ZXIEI,8955
40
+ narvy/ios/trust_all_context.py,sha256=H_iVaEmJecLAGIpPEGZnDkot9BmMuQVDcXPNWunEdcQ,1993
41
+ narvy/rules/android.yml,sha256=MmG_-jTUy5nOZDkTerWFLjfIu0GQANQiZeJz9H0lqGM,8172
42
+ narvy/rules/ios_swift.yml,sha256=RSPQPcETrS9KMUxaHFrS2Bh3d430N491RI1u3JrijxA,12318
43
+ narvy/rules/android/config.yml,sha256=qUtCazhmV02FZd81qoi9ZYq8dH2uAGGPk70Z_AfU4X0,4909
44
+ narvy/rules/android/crypto.yml,sha256=wyf4fuDJG5m4_klqTJw7zTPhTo1sDHBqsXi_d_P5W3Q,1744
45
+ narvy/rules/android/secrets.yml,sha256=pK1pTlc_nQcQvjF4NgvGtlL46Qo_psKOaBZNdRphBI4,8555
46
+ narvy/rules/android/storage.yml,sha256=-0rkA-XtIlnCuPxJpG7NsI9IkDDRGrnq23Y5LFrXD80,1475
47
+ narvy/rules/android/webview.yml,sha256=kWIGPuQ6UVLbgGyvj-3MsaIf8NrLn-NG4JZW4sYh7Cw,1049
48
+ narvy/rules/ios/objc/crypto.yml,sha256=yPPqcDCbM8Gc8jGdFowrudZPGXT0vnvV5DrtpJmMn1Y,2874
49
+ narvy/rules/ios/objc/network.yml,sha256=2ryYViC43FFuUm9O4CtFojcrNWbM6fU5CM9nDfW_kCA,4295
50
+ narvy/rules/ios/objc/secrets.yml,sha256=8NFYEN3uzsj3vFKgbl4hDBalLVn2I0nGwfeLWqashxQ,4356
51
+ narvy/rules/ios/objc/storage.yml,sha256=asXrK7YeJZTa76uDpYv7LgS3rEl7PRRcuf7SKuSwolA,2788
52
+ narvy/rules/ios/objc/webview.yml,sha256=uusi2CQLrQrcrKmDJ1e1q51lkmG5aXUNH8GwrLAQRl4,2985
53
+ narvy/rules/web/go.yml,sha256=NhjOvlmUllanfguDAyJH9P-n0vYl7a0wXFMFOhWzpLk,100033
54
+ narvy/rules/web/java.yml,sha256=BzzA02D7nyccF2dUv0HSw3Ap53EmQtWcV4s7G9RLtLk,55805
55
+ narvy/rules/web/javascript.yml,sha256=wx7ZAF9a2yn5Ymu8k_lp-SSVA25MygIP47o1YHhg_48,147582
56
+ narvy/rules/web/kotlin.yml,sha256=7oKcqfJ4Gfcz7yte6MobH8aUnoebkVs0e2ihoxYB5Xk,14360
57
+ narvy/rules/web/php.yml,sha256=PE3w9HXW3o8HPKOQW9RsubqMY9_u2bBMpAlDuXQP85c,54459
58
+ narvy/rules/web/python.yml,sha256=O4-YjldmNgtFrb-KUBoce_fycXyOAq3qoj9NYAqD34s,140549
59
+ narvy/rules/web/ruby.yml,sha256=DMeiAR7ZdWG34lKQ9PS2gmZv0AIK1NrwhYpv_A-29kg,25949
60
+ narvy/rules/web/rust.yml,sha256=oY0srMOsGM7D3dr8ciN0H2lzy6ii2AGo-xaizsm9Z8A,9265
61
+ narvy/rules/web/secrets.yml,sha256=8YX4fUKWW4RZ5oau-Iog8JhMteomDmkN3IDYj5tx7tM,41287
62
+ narvy/rules/web/secrets_supplement.yml,sha256=-Qda5MljM189L3DZ8DXPWbZZnmzDUyjVOSEXgCnMFVc,9924
63
+ narvy/rules/web/local/csharp_narvy/config.yml,sha256=pTAxBVLzid8-7aYk2biZZfffBUbQP-qS1fkWkx4esWs,2082
64
+ narvy/rules/web/local/csharp_narvy/crypto.yml,sha256=oRBhPjI7wc8r2ba9RBOdUQsB_EXBtPNbWeqlNRH6PAM,2313
65
+ narvy/rules/web/local/csharp_narvy/deserialization.yml,sha256=PfegDUx7ysFpv644ihytaPmhP4PpHNPGS3f7UcjkekQ,2254
66
+ narvy/rules/web/local/csharp_narvy/injection.yml,sha256=7tiI5hvF4nxobPXXvKQCoApTpX6SN6S-_vzfbt5OWZU,5140
67
+ narvy/rules/web/local/csharp_narvy/xxe.yml,sha256=2y1Kycs2FWPWf0zVZy_49XeMLnUQgwuptL5_8P6ifPM,1633
68
+ narvy/rules/web/local/java_narvy/auth_jwt.yml,sha256=wFN81YbT7bAYjv42k6hSKkms9RzZ1iZPG7WKOoZoKFg,5046
69
+ narvy/rules/web/local/java_narvy/deserialization.yml,sha256=wiMTGKpqWup0HzGhpIWE1EEoUr8GCqo4iN5lMr_vdm4,4415
70
+ narvy/rules/web/local/java_narvy/mybatis.yml,sha256=LcBiPVL7wSKGyjtrNDpzHyt0UEjp7Aw5_ZImj2aPcHk,1697
71
+ narvy/rules/web/local/java_narvy/snakeyaml.yml,sha256=_DkLcyHyOaZTrozriqFRIPAt6KyaMdlkGMcrOizJJyA,1269
72
+ narvy/rules/web/local/java_narvy/spring_authz.yml,sha256=RrdtjgijRpr_VFs0sscbWpnAvgD91Tlmd_yFEC7pI2U,1137
73
+ narvy/rules/web/local/java_narvy/spring_config.yml,sha256=5f3UZRueTfQF1Q9-eBaJXc9NZcOZLCKXge6KW30eU1g,2405
74
+ narvy/rules/web/local/java_narvy/spring_hardening.yml,sha256=REZKg5t92eq32riTnvaijPpFukZ_5G6gK8usZAEVGJY,10940
75
+ narvy/rules/web/local/java_narvy/sqli.yml,sha256=fMJXogX9XJG-R79Re2OChNcrd0Jf4p22ybDtnLAbFpU,8652
76
+ narvy/rules/web/local/java_narvy/xxe.yml,sha256=immbF7DrTS3rYzeBpwlO2pjuTLyq9DMVrs_mhhCt7Cg,7798
77
+ narvy/sca/__init__.py,sha256=yf6o8Yj140CoYXLNxx8Sg7cxwS0Cfd-WOGi2ueVyqT8,74
78
+ narvy/sca/android_deps.py,sha256=zx1fNZkDuMZ-Zl6UZSzQtezfQ2oHbs82wlBGQPwXVHE,13109
79
+ narvy/sca/ios_deps.py,sha256=72MNCxx1SiEnxrGgfommFe8M33CbWg2aeqtV7co0sNo,21560
80
+ narvy/sca/osv_client.py,sha256=cP3nC5x2IGXqu2FMdjkzveg0WXV8XvXWTsbhdtBLriw,20532
81
+ narvy/sca/web_deps.py,sha256=L2xqvCDs5MWzrcdbEsF-l7P3zWgdKgQJWfG-toMBrFU,34409
82
+ narvy/web/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
83
+ narvy/web/nuclei_binary.py,sha256=S5zzPSEYiYDjS830INHOFD44WFa0k2Th4IMfsvMYIm0,3570
84
+ narvy/web/scan_blocklist.py,sha256=toKPe4Z7OH2TAfTaTTpplZrzaRiP8LIRtvq-wx-hNtM,3250
85
+ narvy/web/scanner.py,sha256=liJRqtSKMPJ-75_C9Kpzls7TuC6nzxdzQdoFvmahd6g,34687
86
+ narvy/web/source_analyzer.py,sha256=CNzb2E0B1Ive-wIFOl7cDpGyTfxoBTLMbAxzN8zpyxs,27313
87
+ narvy/web/ssrf_guard.py,sha256=Fem_GbuTSQT9P88KFksbf1ru7O0kQqC_buFVFU_E45M,12806
88
+ narvy_cli-1.0.0.dist-info/licenses/LICENSE,sha256=oRS_yX70S6CuKY_6fQvPIaql-fucDYMZG9Mo_I9VPww,11341
89
+ narvy_cli-1.0.0.dist-info/METADATA,sha256=1VwaPJdcGq0bOobxfNusqTnp8CwzV0Ee0SkBBzWUcgc,6390
90
+ narvy_cli-1.0.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
91
+ narvy_cli-1.0.0.dist-info/entry_points.txt,sha256=B9il_Cpsbt7F1Gk6ePswqmu7oduBalCtbJheDFSbQu8,41
92
+ narvy_cli-1.0.0.dist-info/top_level.txt,sha256=z_E-mw3b3pmziLN825vSN-d7FqP5-QC4Cel_nRy-Jm8,6
93
+ narvy_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ narvy = narvy.main:cli