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
narvy/main.py ADDED
@@ -0,0 +1,1983 @@
1
+ """Command-line entry point: argument parsing and the scan/audit commands."""
2
+ import argparse
3
+ import getpass
4
+ import os
5
+ import re
6
+ import shutil
7
+ import sys
8
+ import tempfile
9
+ import time
10
+ import json
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List
13
+ from rich.console import Console
14
+ from rich.table import Table
15
+ from rich.progress import Progress
16
+ from rich.panel import Panel
17
+ from rich import box as rich_box
18
+ from rich.markup import escape as rich_escape
19
+ from rich.prompt import Confirm
20
+
21
+ from narvy import __version__
22
+ from narvy.reporter import generate_sarif_report
23
+ from .decompiler import decompile_apk
24
+ from .rule_engine import load_rules_from_dir, run_rules_on_file
25
+ from . import auth, uploader
26
+ from .third_party_filter import get_own_package_roots, resolve_file_scope, check_android_override
27
+ from .scope_config import load_scope_config
28
+ from . import semgrep_engine
29
+ from . import crypto_taint_lite
30
+ from . import native_hardening
31
+ from .doctor import run_doctor
32
+ from .ios import binary_analyzer as ios_binary_analyzer
33
+ from .ios import source_analyzer as ios_source_analyzer
34
+ from .ios.third_party_filter import IOS_VENDOR_DIR_NAMES
35
+ from .android import source_analyzer as android_source_analyzer
36
+ from .android import split_bundle
37
+ from .web import source_analyzer as web_source_analyzer
38
+ from .sca import android_deps as sca_android_deps
39
+ from .sca import ios_deps as sca_ios_deps
40
+ from .sca import web_deps as sca_web_deps
41
+ from .web.scanner import NucleiScanner
42
+ from .web.scan_blocklist import check_blocklist, ScanBlocked, REFUSAL_MESSAGE
43
+ from .web.ssrf_guard import validate_url as web_validate_url, SSRFBlocked
44
+ from .host.ssh_exec import HostConn, SSHExecError
45
+ from .host.audit import audit_host
46
+ from .host.report import normalize_host_findings, severity_counts as host_severity_counts
47
+ # cloud/aws_scan.py and its boto3 dependency are imported lazily inside
48
+ # cmd_cloud_scan(): boto3 is an opt-in extra, not a dependency of the whole CLI.
49
+
50
+ # Human/progress/status UI goes to stderr so stdout carries only the machine
51
+ # payload (JSON/SARIF, or the results table via out_console).
52
+ console = Console(stderr=True)
53
+ out_console = Console()
54
+
55
+ WEB_MAX_RATE_LIMIT = 50
56
+
57
+ # getpass raises termios.error/EOFError under CI (non-TTY/closed stdin); termios is POSIX-only.
58
+ _GETPASS_ERRORS = [EOFError, KeyboardInterrupt, OSError]
59
+ try: # pragma: no cover - platform dependent
60
+ import termios as _termios
61
+ _GETPASS_ERRORS.append(_termios.error)
62
+ except ImportError: # pragma: no cover - Windows
63
+ pass
64
+ _GETPASS_ERRORS = tuple(_GETPASS_ERRORS)
65
+
66
+ # JADX takes -Xmx<size>; anything else leaks a raw JVM error, so gate it.
67
+ _MAX_MEM_RE = re.compile(r"^\d+[mMgGkK]?$")
68
+
69
+ _SEVERITY_LEVEL = {"critical": 5, "high": 4, "medium": 3, "low": 2, "info": 1}
70
+
71
+ _IOS_SOURCE_MARKER_EXTS = (".xcodeproj", ".xcworkspace")
72
+ _IOS_SOURCE_MARKER_FILES = ("Package.swift",)
73
+ # `.h` is deliberately absent: a header is just as likely C or C++, so it
74
+ # would select ios-source for any repo vendoring a C library.
75
+ _IOS_SOURCE_CODE_EXTS = (".swift", ".m", ".mm")
76
+ _IOS_DETECT_MAX_DEPTH = 6
77
+
78
+ _ANDROID_BINARY_EXTS = (".apk", ".aab")
79
+ _ANDROID_GRADLE_ROOT_FILES = ("build.gradle", "build.gradle.kts", "settings.gradle", "settings.gradle.kts")
80
+ _ANDROID_MANIFEST_FILENAME = "AndroidManifest.xml"
81
+
82
+ # Gradle is a general-purpose JVM build tool, so a Gradle file alone is not
83
+ # evidence of Android. Any one of the markers below is required instead.
84
+ _ANDROID_GRADLE_PLUGIN_IDS = (
85
+ "com.android.application",
86
+ "com.android.library",
87
+ "com.android.dynamic-feature",
88
+ "com.android.test",
89
+ "com.android.asset-pack",
90
+ "com.android.fused-library",
91
+ )
92
+ # Anchored so a bare "android" in a comment or a coordinate cannot match. The
93
+ # `android { }` block is the only marker left when AGP is wired via buildSrc.
94
+ _ANDROID_GRADLE_DSL_RE = re.compile(
95
+ r"""apply\s+plugin\s*:\s*['"]android(-library)?['"]"""
96
+ r"""|^\s*android\s*\{"""
97
+ r"""|^\s*applicationId\s*[=(\s]""",
98
+ re.MULTILINE,
99
+ )
100
+ _ANDROID_GRADLE_FILE_SUFFIXES = (".gradle", ".gradle.kts")
101
+ _ANDROID_MARKER_PROPERTY_FILES = ("gradle.properties",)
102
+ _ANDROID_MARKER_PROPERTIES = ("android.useAndroidX", "android.enableJetifier")
103
+ _ANDROID_VERSION_CATALOG_FILENAME = "libs.versions.toml"
104
+ _ANDROID_BUILD_FILE_SNIFF_BYTES = 512 * 1024
105
+ _ANDROID_DETECT_MAX_DEPTH = 6
106
+ _ANDROID_SKIP_DIR_NAMES = frozenset({
107
+ "build", ".gradle", ".idea", ".git", "out", ".cxx", "node_modules",
108
+ ".vscode", ".vs", "captures",
109
+ })
110
+
111
+
112
+ _WEB_SOURCE_MARKER_FILES = (
113
+ "package.json", "requirements.txt", "setup.py", "pyproject.toml",
114
+ "Pipfile", "composer.json", "go.mod", "go.sum",
115
+ "Gemfile", "Gemfile.lock", "Cargo.toml", "Cargo.lock",
116
+ "packages.config", "Directory.Packages.props",
117
+ )
118
+ # Markers with no fixed filename: .NET manifests are named after their project.
119
+ _WEB_SOURCE_MARKER_SUFFIXES = (
120
+ ".gemspec", ".csproj", ".fsproj", ".vbproj", ".sln", ".slnx",
121
+ )
122
+ _WEB_SOURCE_DETECT_MAX_DEPTH = 3
123
+
124
+
125
+ def _looks_like_web_source_dir(path: str) -> bool:
126
+ """Only valid after the iOS-source and Android-source checks both returned False."""
127
+ base_depth = path.rstrip(os.sep).count(os.sep)
128
+ for root, dirs, files in os.walk(path):
129
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
130
+ if depth >= _WEB_SOURCE_DETECT_MAX_DEPTH:
131
+ dirs[:] = []
132
+ continue
133
+ dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")
134
+ and d not in ("node_modules", "vendor")]
135
+ for f in files:
136
+ if f in _WEB_SOURCE_MARKER_FILES or f.endswith(_WEB_SOURCE_MARKER_SUFFIXES):
137
+ return True
138
+ return False
139
+
140
+
141
+ class UnsupportedScanTarget(Exception):
142
+ """Target is neither a recognized binary format nor a recognized source project."""
143
+
144
+
145
+ def _looks_like_ios_source_dir(path: str) -> bool:
146
+ base_depth = path.rstrip(os.sep).count(os.sep)
147
+ for root, dirs, files in os.walk(path):
148
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
149
+ if depth >= _IOS_DETECT_MAX_DEPTH:
150
+ dirs[:] = []
151
+ continue
152
+ dirs[:] = [d for d in dirs if d not in IOS_VENDOR_DIR_NAMES and d not in (".git", "node_modules")]
153
+ for d in dirs:
154
+ if d.endswith(_IOS_SOURCE_MARKER_EXTS):
155
+ return True
156
+ for f in files:
157
+ if f in _IOS_SOURCE_MARKER_FILES or f.endswith(_IOS_SOURCE_CODE_EXTS):
158
+ return True
159
+ return False
160
+
161
+
162
+ def _has_ios_project_marker(path: str) -> bool:
163
+ """Strong iOS signal: an Xcode project/workspace or SwiftPM Package.swift (a bare .swift/.m file is not enough)."""
164
+ base_depth = path.rstrip(os.sep).count(os.sep)
165
+ for root, dirs, files in os.walk(path):
166
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
167
+ if depth >= _IOS_DETECT_MAX_DEPTH:
168
+ dirs[:] = []
169
+ continue
170
+ dirs[:] = [d for d in dirs if d not in IOS_VENDOR_DIR_NAMES and d not in (".git", "node_modules")]
171
+ for d in dirs:
172
+ if d.endswith(_IOS_SOURCE_MARKER_EXTS):
173
+ return True
174
+ for f in files:
175
+ if f in _IOS_SOURCE_MARKER_FILES:
176
+ return True
177
+ return False
178
+
179
+
180
+ def _file_head_contains(path: str, needles, regex=None) -> bool:
181
+ """True if the head of `path` contains any of `needles` or matches `regex`."""
182
+ try:
183
+ with open(path, "r", encoding="utf-8", errors="ignore") as f:
184
+ head = f.read(_ANDROID_BUILD_FILE_SNIFF_BYTES)
185
+ except OSError:
186
+ return False
187
+ for needle in needles:
188
+ if needle in head:
189
+ return True
190
+ return bool(regex and regex.search(head))
191
+
192
+
193
+ def _looks_like_android_source_dir(path: str) -> bool:
194
+ """True if path holds an AndroidManifest.xml or an Android Gradle marker."""
195
+ base_depth = path.rstrip(os.sep).count(os.sep)
196
+ for root, dirs, files in os.walk(path):
197
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
198
+ if depth >= _ANDROID_DETECT_MAX_DEPTH:
199
+ dirs[:] = []
200
+ continue
201
+ dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")]
202
+ if _ANDROID_MANIFEST_FILENAME in files:
203
+ return True
204
+ for f in files:
205
+ if f.endswith(_ANDROID_GRADLE_FILE_SUFFIXES) or f == _ANDROID_VERSION_CATALOG_FILENAME:
206
+ if _file_head_contains(os.path.join(root, f), _ANDROID_GRADLE_PLUGIN_IDS,
207
+ _ANDROID_GRADLE_DSL_RE):
208
+ return True
209
+ elif f in _ANDROID_MARKER_PROPERTY_FILES:
210
+ if _file_head_contains(os.path.join(root, f), _ANDROID_MARKER_PROPERTIES):
211
+ return True
212
+ return False
213
+
214
+
215
+ def _looks_like_jvm_non_android_dir(path: str) -> bool:
216
+ """Only valid after _looks_like_android_source_dir and _looks_like_web_source_dir said no."""
217
+ base_depth = path.rstrip(os.sep).count(os.sep)
218
+ for root, dirs, files in os.walk(path):
219
+ depth = root.rstrip(os.sep).count(os.sep) - base_depth
220
+ if depth >= _WEB_SOURCE_DETECT_MAX_DEPTH:
221
+ dirs[:] = []
222
+ continue
223
+ dirs[:] = [d for d in dirs if d not in _ANDROID_SKIP_DIR_NAMES and not d.startswith(".")]
224
+ for f in files:
225
+ if f == "pom.xml" or f in _ANDROID_GRADLE_ROOT_FILES:
226
+ return True
227
+ return False
228
+
229
+
230
+ def _detect_scan_mode(target_path: str) -> str:
231
+ """Detect scan mode (android / android-bundle / android-source / ios-binary / ios-source / web-source); mobile markers win over the web-source fallback."""
232
+ if os.path.isdir(target_path):
233
+ if _has_ios_project_marker(target_path):
234
+ return "ios-source"
235
+ # Android/web manifests beat a stray .m/.mm/.swift file (e.g. a Cgo bridge in a Go repo).
236
+ if _looks_like_android_source_dir(target_path):
237
+ return "android-source"
238
+ if _looks_like_web_source_dir(target_path):
239
+ return "web-source"
240
+ if _looks_like_jvm_non_android_dir(target_path):
241
+ return "web-source"
242
+ # Weak fallback: bare Swift/Obj-C source with no competing manifest.
243
+ if _looks_like_ios_source_dir(target_path):
244
+ return "ios-source"
245
+ raise UnsupportedScanTarget(
246
+ "This doesn't look like a supported scan target (not an .apk, not "
247
+ "an .aab, not an .ipa, not an iOS Xcode/Swift project, not an "
248
+ "Android Gradle project, not a web/backend project with a "
249
+ "recognized manifest - package.json/requirements.txt/"
250
+ "composer.json/go.mod/Gemfile/Cargo.toml/pom.xml/build.gradle/"
251
+ "*.csproj/*.sln/etc)."
252
+ )
253
+ ext = os.path.splitext(target_path)[1].lower()
254
+ if ext == ".ipa":
255
+ return "ios-binary"
256
+ if ext in _ANDROID_BINARY_EXTS:
257
+ return "android"
258
+ # .apkm / .xapk / .apks are ZIPs of sibling APKs, unreadable by jadx.
259
+ if split_bundle.is_split_bundle(target_path):
260
+ return "android-bundle"
261
+ raise UnsupportedScanTarget(
262
+ f"Unrecognized file type '{ext or os.path.basename(target_path)}'. "
263
+ "Narvy CLI currently supports: .apk or .aab (Android), .apkm / "
264
+ ".xapk / .apks (Android split-APK bundles), .ipa "
265
+ "(iOS binary - unencrypted only), or a directory containing an "
266
+ "Android Gradle project, an iOS Xcode/Swift project, or a web/"
267
+ "backend project (source-mode scanning)."
268
+ )
269
+
270
+
271
+ def _positive_int(value):
272
+ """argparse type: a whole number >= 1. Rejects 0 and negatives client-side
273
+ with a clean exit-2 error rather than forwarding a nonsense --limit."""
274
+ try:
275
+ ivalue = int(value)
276
+ except (TypeError, ValueError):
277
+ raise argparse.ArgumentTypeError(f"must be an integer >= 1, got {value!r}")
278
+ if ivalue < 1:
279
+ raise argparse.ArgumentTypeError(f"must be >= 1, got {ivalue}")
280
+ return ivalue
281
+
282
+
283
+ def _format_asset_limit_error(data):
284
+ """Human message for a 402 asset_limit_exceeded response, or None if this
285
+ isn't that case (so the caller falls back to the raw slug)."""
286
+ if not isinstance(data, dict) or data.get("error") != "asset_limit_exceeded":
287
+ return None
288
+ upgrade_url = data.get("upgrade_url") or "https://narvy.io"
289
+ return (
290
+ "You have reached your asset limit. Free a slot or upgrade at "
291
+ f"{upgrade_url}."
292
+ )
293
+
294
+
295
+ def _preflight_output_writable(out_file):
296
+ """Return an error string if out_file can't be written, else None. Checked
297
+ BEFORE a multi-minute scan so an unwritable path fails fast, not at the end."""
298
+ parent = os.path.dirname(os.path.abspath(out_file)) or "."
299
+ if not os.path.isdir(parent):
300
+ return f"the directory {parent} does not exist"
301
+ if not os.access(parent, os.W_OK):
302
+ return f"the directory {parent} is not writable"
303
+ if os.path.exists(out_file) and not os.access(out_file, os.W_OK):
304
+ return f"{out_file} already exists and is not writable"
305
+ return None
306
+
307
+
308
+ def cmd_login(args):
309
+ token = args.token
310
+ if not token:
311
+ # Piped stdin (echo TOKEN | narvy login) must work; only a real terminal gets the getpass prompt.
312
+ try:
313
+ stdin_is_tty = bool(sys.stdin) and sys.stdin.isatty()
314
+ except (ValueError, OSError):
315
+ stdin_is_tty = False
316
+ if stdin_is_tty:
317
+ try:
318
+ token = getpass.getpass(
319
+ "Paste your Narvy API token (Account Settings > API Access): "
320
+ )
321
+ except _GETPASS_ERRORS:
322
+ token = None
323
+ else:
324
+ try:
325
+ token = sys.stdin.readline().strip()
326
+ except (EOFError, OSError):
327
+ token = None
328
+ if not token:
329
+ console.print(
330
+ "[bold red]No token provided.[/bold red] Pass [bold]--token <TOKEN>[/bold] "
331
+ "or run [bold]narvy login[/bold] interactively."
332
+ )
333
+ raise SystemExit(1)
334
+ status, data = uploader.whoami(token)
335
+ if status != 200:
336
+ console.print(f"[bold red]Login failed:[/bold red] {data.get('error', status)}")
337
+ raise SystemExit(1)
338
+ auth.save_token(token, data["email"], data["plan"])
339
+ console.print(f"[bold green]Logged in as {data['email']} ({data['plan']} plan).[/bold green]")
340
+
341
+
342
+ def cmd_logout(args):
343
+ auth.logout()
344
+ console.print("[bold green]Logged out.[/bold green]")
345
+
346
+
347
+ def cmd_scans(args):
348
+ """List recent scans on the logged-in account."""
349
+ creds = auth.load_credentials()
350
+ if not creds:
351
+ console.print("[bold red]Not logged in.[/bold red] Run `narvy login` first.")
352
+ raise SystemExit(1)
353
+
354
+ status, data = uploader.list_scans(creds["token"], limit=args.limit, status=args.status)
355
+ if status == 404:
356
+ console.print(
357
+ "[bold red]Scan history isn't available from this server yet[/bold red] "
358
+ "(HTTP 404 from /api/v1/scans - the endpoint isn't deployed). "
359
+ "Your scans are still visible at "
360
+ "[link=https://narvy.io]narvy.io[/link] under your dashboard "
361
+ "in the meantime."
362
+ )
363
+ raise SystemExit(1)
364
+ if status != 200:
365
+ error_msg = data.get("error") or data.get("message") or data
366
+ console.print(f"[bold red]Could not fetch scan history ({status}):[/bold red] {error_msg}")
367
+ raise SystemExit(1)
368
+
369
+ scans = data.get("scans", [])
370
+ if args.format == "json":
371
+ print(json.dumps(scans, indent=2))
372
+ return
373
+
374
+ if not scans:
375
+ console.print(
376
+ "[bold yellow]No scans found on your account yet.[/bold yellow] "
377
+ "Run `narvy scan <path> --upload` to submit one."
378
+ )
379
+ return
380
+
381
+ table = Table(title=f"Recent scans ({creds['email']})")
382
+ table.add_column("Scan ID", style="dim")
383
+ table.add_column("App")
384
+ table.add_column("Platform")
385
+ table.add_column("Type")
386
+ table.add_column("Status")
387
+ table.add_column("Findings", justify="right")
388
+ table.add_column("Submitted")
389
+ for s in scans:
390
+ scan_id = str(s.get("scan_id", "?"))
391
+ status_val = str(s.get("status", "-"))
392
+ status_style = {"done": "green", "failed": "red", "running": "yellow",
393
+ "queued": "dim", "scheduled": "dim", "pending_sast": "yellow"}.get(status_val, "white")
394
+ table.add_row(
395
+ scan_id[:8],
396
+ rich_escape(str(s.get("app_name") or "-")),
397
+ str(s.get("platform", "-")),
398
+ str(s.get("analysis_type", "-")),
399
+ f"[{status_style}]{status_val}[/{status_style}]",
400
+ str(s.get("vulnerabilities_found", "-")),
401
+ str(s.get("timestamp", "-")),
402
+ )
403
+ out_console.print(table)
404
+ console.print(f"Full detail at [link=https://narvy.io]narvy.io[/link] under your dashboard.")
405
+
406
+
407
+ def _run_local_scan(apk_path, max_mem, override_config=None, force=False,
408
+ native_apk_path=None, extra_dex_inputs=None):
409
+ """Android binary scan. `extra_dex_inputs`: extra APKs decompiled in the same jadx run;
410
+ `native_apk_path`: the zip the native-hardening pass reads (the ABI split for a bundle)."""
411
+ if native_apk_path is None:
412
+ native_apk_path = apk_path
413
+ decompile_error = None
414
+ sca_cov = None
415
+ with Progress(console=console, disable=not console.is_terminal) as progress:
416
+ task1 = progress.add_task("[green]Decompiling APK...", total=1)
417
+ with tempfile.TemporaryDirectory() as temp_dir:
418
+ ok, decompile_error = decompile_apk(apk_path, temp_dir, max_mem, force=force,
419
+ extra_inputs=extra_dex_inputs)
420
+ if not ok:
421
+ progress.update(task1, completed=1, description="[red]Decompilation Failed.")
422
+ progress.stop()
423
+ # Progress owns the cursor: printing before stop() mangles this.
424
+ console.print(f"\n[bold red]{decompile_error}[/bold red]\n")
425
+ return None
426
+ progress.update(task1, completed=1, description="[green]Decompilation Complete.")
427
+
428
+ task2 = progress.add_task("[cyan]Analyzing files...", total=None)
429
+ rules_path = os.path.join(os.path.dirname(__file__), 'rules', 'android')
430
+ rules = load_rules_from_dir(rules_path)
431
+ progress.update(task2, description=f"[cyan]Analyzing with {len(rules)} rules...")
432
+
433
+ all_findings = []
434
+ all_source_files = [os.path.join(root, file) for root, _, files in os.walk(temp_dir) for file in files if file.endswith(('.java', '.kt', '.xml'))]
435
+
436
+ own_roots = get_own_package_roots(temp_dir)
437
+ if own_roots:
438
+ console.print(f"[dim]App package roots: {', '.join(sorted(own_roots))} (scoping analysis to this app's own code).[/dim]")
439
+ else:
440
+ console.print("[dim]Could not determine app package from manifest - falling back to known-SDK denylist.[/dim]")
441
+
442
+ source_files = [f for f in all_source_files if resolve_file_scope(f, own_roots, override_config)]
443
+ skipped = len(all_source_files) - len(source_files)
444
+ if skipped:
445
+ console.print(f"[dim]Skipped {skipped} third-party/framework files ({skipped * 100 // len(all_source_files)}%).[/dim]")
446
+ if override_config:
447
+ override_calls = [check_android_override(f, override_config) for f in all_source_files]
448
+ forced_own = override_calls.count('own')
449
+ forced_vendor = override_calls.count('vendor')
450
+ if forced_own or forced_vendor:
451
+ console.print(
452
+ f"[dim].narvy-scope.yml overrides ({override_config.source_path}): "
453
+ f"{forced_own} file(s) forced first-party, "
454
+ f"{forced_vendor} file(s) forced third-party-excluded.[/dim]"
455
+ )
456
+
457
+ progress.update(task2, total=len(source_files))
458
+ for file_path in source_files:
459
+ relative_path = os.path.relpath(file_path, temp_dir)
460
+ findings_in_file = run_rules_on_file(file_path, rules)
461
+ for finding in findings_in_file:
462
+ finding['file_path'] = relative_path
463
+ finding['engine'] = 'regex'
464
+ all_findings.extend(findings_in_file)
465
+
466
+ if file_path.endswith(('.java', '.kt')):
467
+ try:
468
+ with open(file_path, 'r', encoding='utf-8', errors='ignore') as fh:
469
+ taint_findings = crypto_taint_lite.check_file(fh.read())
470
+ except OSError:
471
+ taint_findings = []
472
+ for finding in taint_findings:
473
+ finding['file_path'] = relative_path
474
+ all_findings.extend(taint_findings)
475
+
476
+ progress.update(task2, advance=1)
477
+ progress.update(task2, description="[green]Analysis Complete.")
478
+
479
+ # Semgrep takes a directory plus exclude globs, not a file list.
480
+ if semgrep_engine.is_available():
481
+ console.print("[dim]Running deep structural analysis (AST-aware, taint-tracking) - can take several minutes on larger apps, this is normal, don't interrupt.[/dim]")
482
+ task3 = progress.add_task("[cyan]Deep structural analysis...", total=None)
483
+ semgrep_findings = semgrep_engine.run_semgrep(temp_dir, own_roots=own_roots, override_config=override_config)
484
+ if semgrep_findings:
485
+ all_findings.extend(semgrep_findings)
486
+ _sg_note = semgrep_engine.degraded_coverage_note()
487
+ if _sg_note:
488
+ progress.update(task3, completed=1, description="[yellow]Deep analysis INCOMPLETE (partial coverage).")
489
+ console.print(f"[bold yellow]{_sg_note}[/bold yellow]")
490
+ else:
491
+ progress.update(task3, completed=1, description=f"[green]Deep analysis complete ({len(semgrep_findings or [])} findings).")
492
+ else:
493
+ console.print("[dim]semgrep not found - install with `pip install semgrep` for deeper structural detection. Continuing with regex rules only.[/dim]")
494
+
495
+ # Runs against the raw zip at apk_path, not the decompiled temp_dir.
496
+ console.print("[dim]Checking third-party dependencies against OSV.dev (SCA)...[/dim]")
497
+ try:
498
+ sca_findings, sca_rule_defs, sca_stats = sca_android_deps.scan(apk_path)
499
+ all_findings.extend(sca_findings)
500
+ rules.extend(sca_rule_defs)
501
+ sca_cov = _sca_coverage(sca_stats)
502
+ console.print(
503
+ f"[dim]SCA: checked {sca_stats['unique_dependencies_checked']} unique "
504
+ f"dependencies against OSV.dev, found {sca_stats['cves_found']} known "
505
+ f"CVE(s) across {sca_stats['vulnerable_dependencies']} dependency(ies).[/dim]"
506
+ )
507
+ if sca_stats.get("dependencies_capped"):
508
+ _detected = sca_stats["unique_dependencies_detected"]
509
+ _checked = sca_stats["unique_dependencies_checked"]
510
+ console.print(
511
+ f"[bold yellow]SCA is INCOMPLETE: {_detected} unique "
512
+ f"dependencies were found but only {_checked} were queried "
513
+ f"({_detected - _checked} not checked) - the free OSV.dev API "
514
+ f"is rate-considerate-capped. Treat 0 CVEs on the remainder "
515
+ f"as UNKNOWN, not as clean.[/bold yellow]"
516
+ )
517
+ except Exception as e:
518
+ console.print(f"[dim]SCA dependency check failed (continuing without it): {e}[/dim]")
519
+
520
+ console.print("[dim]Checking native library (.so) hardening flags (NX/PIE/RELRO/canary/RPATH)...[/dim]")
521
+ try:
522
+ nh_findings, nh_rule_defs, nh_stats = native_hardening.scan(native_apk_path)
523
+ all_findings.extend(nh_findings)
524
+ rules.extend(nh_rule_defs)
525
+ if nh_stats["arch_analyzed"]:
526
+ console.print(
527
+ f"[dim]Native hardening: checked {nh_stats['libs_analyzed']} native "
528
+ f"librar{'y' if nh_stats['libs_analyzed'] == 1 else 'ies'} "
529
+ f"({nh_stats['own_libs_analyzed']} app-owned, "
530
+ f"{nh_stats['vendor_libs_analyzed']} known third-party/bundled) on "
531
+ f"{nh_stats['arch_analyzed']}, found {nh_stats['findings']} hardening "
532
+ f"gap(s).[/dim]"
533
+ )
534
+ elif not nh_stats["lief_available"]:
535
+ console.print("[dim]Native hardening check skipped (lief not installed).[/dim]")
536
+ except Exception as e:
537
+ console.print(f"[dim]Native hardening check failed (continuing without it): {e}[/dim]")
538
+ return all_findings, rules, {"sca_coverage": sca_cov}
539
+
540
+
541
+ def _sca_coverage(stats):
542
+ """SCA coverage flag for machine output: 'complete', or 'partial' when a rate limit truncated the dependency queries."""
543
+ detected = stats.get("unique_dependencies_detected")
544
+ if detected is None:
545
+ detected = stats.get("total_dependencies_detected")
546
+ checked = stats.get("unique_dependencies_checked", 0)
547
+ if detected is None:
548
+ detected = checked
549
+ reasons = []
550
+ if stats.get("dependencies_capped"):
551
+ reasons.append("osv_dependency_cap")
552
+ if stats.get("github_rate_limited"):
553
+ reasons.append("github_advisory_rate_limit")
554
+ cov = {
555
+ "status": "partial" if reasons else "complete",
556
+ "dependencies_detected": detected,
557
+ "dependencies_checked": checked,
558
+ }
559
+ if reasons:
560
+ cov["degraded_reasons"] = reasons
561
+ return cov
562
+
563
+
564
+ def _warn_github_advisory_truncated(stats):
565
+ if not stats.get("github_rate_limited"):
566
+ return
567
+ console.print(
568
+ f"[bold yellow]SCA is INCOMPLETE: the GitHub Advisory database was "
569
+ f"rate-limited after {stats.get('github_queries_made', 0)} query(ies), so "
570
+ f"the remaining dependencies were checked only against OSV.dev + the local "
571
+ f"CVE DB. Set a GITHUB_TOKEN environment variable to raise the limit. Treat "
572
+ f"0 CVEs on the un-queried remainder as UNKNOWN, not as clean.[/bold yellow]"
573
+ )
574
+
575
+
576
+ def _run_ios_source_scan(source_dir):
577
+ """None on hard failure, else (findings, rule_defs, meta)."""
578
+ with Progress(console=console, disable=not console.is_terminal) as progress:
579
+ task = progress.add_task("[cyan]Scanning Swift/Objective-C source...", total=None)
580
+ result = ios_source_analyzer.analyze_source(source_dir)
581
+ progress.update(task, completed=1, description="[green]Analysis Complete." if (isinstance(result, dict) and result.get("ok")) else "[red]Analysis failed.")
582
+
583
+ if not result["ok"]:
584
+ console.print(f"\n[bold red]{result['error']}[/bold red]\n")
585
+ return None
586
+
587
+ for note in result["notes"]:
588
+ console.print(f"[dim]{note}[/dim]")
589
+ console.print(
590
+ f"[dim]Scanned {result['swift_files_scanned']} Swift file(s) and "
591
+ f"{result['objc_files_scanned']} Objective-C file(s); skipped "
592
+ f"{result['skipped_third_party']} third-party/vendored file(s).[/dim]"
593
+ )
594
+
595
+ console.print("[dim]Checking Podfile.lock / Package.resolved dependencies for known CVEs (SCA)...[/dim]")
596
+ sca_cov = None
597
+ try:
598
+ sca_findings, sca_rule_defs, sca_stats = sca_ios_deps.scan_source(source_dir)
599
+ result["findings"].extend(sca_findings)
600
+ result["rule_defs"].extend(sca_rule_defs)
601
+ sca_cov = _sca_coverage(sca_stats)
602
+ console.print(
603
+ f"[dim]SCA: checked {sca_stats['unique_dependencies_checked']} unique "
604
+ f"dependencies, found {sca_stats['cves_found']} known CVE(s) across "
605
+ f"{sca_stats['vulnerable_dependencies']} dependency(ies).[/dim]"
606
+ )
607
+ _warn_github_advisory_truncated(sca_stats)
608
+ except Exception as e:
609
+ console.print(f"[dim]SCA dependency check failed (continuing without it): {e}[/dim]")
610
+
611
+ return result["findings"], result["rule_defs"], {"sca_coverage": sca_cov}
612
+
613
+
614
+ def _run_android_source_scan(source_dir, override_config=None):
615
+ """None on hard failure, else (findings, rule_defs). No decompile step."""
616
+ with Progress(console=console, disable=not console.is_terminal) as progress:
617
+ task = progress.add_task("[cyan]Scanning Android Gradle/Java/Kotlin source...", total=None)
618
+ result = android_source_analyzer.analyze_source(source_dir, override_config=override_config)
619
+ progress.update(task, completed=1, description="[green]Analysis Complete." if (isinstance(result, dict) and result.get("ok")) else "[red]Analysis failed.")
620
+
621
+ if not result["ok"]:
622
+ console.print(f"\n[bold red]{result['error']}[/bold red]\n")
623
+ return None
624
+
625
+ for note in result["notes"]:
626
+ console.print(f"[dim]{note}[/dim]")
627
+ if result["own_roots"]:
628
+ console.print(
629
+ f"[dim]App package roots: {', '.join(result['own_roots'])} "
630
+ f"(scoping analysis to this app's own code, across "
631
+ f"{result['manifests_found']} module manifest(s) found).[/dim]"
632
+ )
633
+ console.print(
634
+ f"[dim]Scanned {result['code_files_scanned']} Java/Kotlin file(s) and "
635
+ f"{result['xml_files_scanned']} XML file(s); skipped "
636
+ f"{result['skipped_third_party']} third-party/vendored file(s).[/dim]"
637
+ )
638
+
639
+ console.print(
640
+ f"[dim]Checking {sca_web_deps.SUPPORTED_ECOSYSTEMS_LABEL} dependencies "
641
+ f"(build.gradle/build.gradle.kts/Gemfile.lock/etc.) for known CVEs (SCA)...[/dim]"
642
+ )
643
+ sca_cov = None
644
+ try:
645
+ sca_findings, sca_rule_defs, sca_stats = sca_web_deps.scan(source_dir)
646
+ result["findings"].extend(sca_findings)
647
+ result["rule_defs"].extend(sca_rule_defs)
648
+ sca_cov = _sca_coverage(sca_stats)
649
+ console.print(
650
+ f"[dim]SCA: checked {sca_stats['unique_dependencies_checked']} unique "
651
+ f"dependencies against OSV.dev, found {sca_stats['cves_found']} known "
652
+ f"CVE(s) across {sca_stats['vulnerable_dependencies']} dependency(ies).[/dim]"
653
+ )
654
+ if sca_stats.get("dependencies_capped"):
655
+ skipped = sca_stats["unique_dependencies_detected"] - sca_stats["unique_dependencies_checked"]
656
+ console.print(
657
+ f"[bold yellow]SCA is INCOMPLETE: {sca_stats['unique_dependencies_detected']} "
658
+ f"unique dependencies were found but only "
659
+ f"{sca_stats['unique_dependencies_checked']} were queried "
660
+ f"({skipped} not checked) - the free OSV.dev API is rate-"
661
+ f"considerate-capped. Treat 0 CVEs on the remainder as "
662
+ f"UNKNOWN, not as clean.[/bold yellow]"
663
+ )
664
+ except Exception as e:
665
+ console.print(f"[dim]SCA dependency check failed (continuing without it): {e}[/dim]")
666
+
667
+ return result["findings"], result["rule_defs"], {"sca_coverage": sca_cov}
668
+
669
+
670
+ def _run_web_source_scan(source_dir):
671
+ """None on hard failure, else (findings, rule_defs)."""
672
+ with Progress(console=console, disable=not console.is_terminal) as progress:
673
+ task = progress.add_task(
674
+ f"[cyan]Scanning web/backend source "
675
+ f"({web_source_analyzer.SUPPORTED_LANGUAGES_LABEL})...",
676
+ total=None,
677
+ )
678
+ result = web_source_analyzer.analyze_source(source_dir)
679
+ progress.update(task, completed=1, description="[green]Analysis Complete." if (isinstance(result, dict) and result.get("ok")) else "[red]Analysis failed.")
680
+
681
+ if not result["ok"]:
682
+ console.print(f"\n[bold red]{result['error']}[/bold red]\n")
683
+ return None
684
+
685
+ for note in result["notes"]:
686
+ console.print(f"[dim]{note}[/dim]")
687
+ if result["stacks_detected"]:
688
+ console.print(
689
+ f"[dim]Detected stack(s): {', '.join(result['stacks_detected'])} "
690
+ f"({result['files_scanned']} source file(s) in scope).[/dim]"
691
+ )
692
+
693
+ console.print(
694
+ f"[dim]Checking {sca_web_deps.SUPPORTED_ECOSYSTEMS_LABEL} dependencies "
695
+ f"for known CVEs (SCA)...[/dim]"
696
+ )
697
+ sca_cov = None
698
+ try:
699
+ sca_findings, sca_rule_defs, sca_stats = sca_web_deps.scan(source_dir)
700
+ result["findings"].extend(sca_findings)
701
+ result["rule_defs"].extend(sca_rule_defs)
702
+ sca_cov = _sca_coverage(sca_stats)
703
+ console.print(
704
+ f"[dim]SCA: checked {sca_stats['unique_dependencies_checked']} unique "
705
+ f"dependencies against OSV.dev, found {sca_stats['cves_found']} known "
706
+ f"CVE(s) across {sca_stats['vulnerable_dependencies']} dependency(ies).[/dim]"
707
+ )
708
+ if sca_stats.get("dependencies_capped"):
709
+ skipped = sca_stats["unique_dependencies_detected"] - sca_stats["unique_dependencies_checked"]
710
+ console.print(
711
+ f"[yellow]SCA is INCOMPLETE: {sca_stats['unique_dependencies_detected']} "
712
+ f"unique dependencies were found but only "
713
+ f"{sca_stats['unique_dependencies_checked']} were queried "
714
+ f"({skipped} not checked) - the free OSV.dev API is rate-"
715
+ f"considerate-capped. Treat 0 CVEs on the remainder as "
716
+ f"UNKNOWN, not as clean.[/yellow]"
717
+ )
718
+ except Exception as e:
719
+ console.print(f"[dim]SCA dependency check failed (continuing without it): {e}[/dim]")
720
+
721
+ return result["findings"], result["rule_defs"], {"sca_coverage": sca_cov}
722
+
723
+
724
+ def _run_ios_binary_scan(ipa_path, override_config=None):
725
+ """None on hard failure, else (findings, rule_defs, meta) with meta {"encrypted": bool}.
726
+ A FairPlay-encrypted IPA still reports whatever unencrypted metadata exists."""
727
+ with Progress(console=console, disable=not console.is_terminal) as progress:
728
+ task = progress.add_task("[cyan]Extracting and analyzing Mach-O binary...", total=None)
729
+ result = ios_binary_analyzer.analyze_ipa(ipa_path, override_config=override_config)
730
+ progress.update(task, completed=1, description="[green]Analysis Complete." if (isinstance(result, dict) and result.get("ok")) else "[red]Analysis failed.")
731
+
732
+ if not result["ok"]:
733
+ console.print(f"\n[bold red]{result['error']}[/bold red]\n")
734
+ return None
735
+
736
+ if result.get("encrypted"):
737
+ console.print(f"\n[bold yellow]{result['encrypted_message']}[/bold yellow]\n")
738
+ for note in result["notes"]:
739
+ console.print(f"[dim]{note}[/dim]")
740
+
741
+ # Embedded-framework SCA works even on a FairPlay-encrypted IPA.
742
+ console.print("[dim]Checking embedded frameworks for known CVEs (SCA)...[/dim]")
743
+ sca_cov = None
744
+ try:
745
+ sca_findings, sca_rule_defs, sca_stats = sca_ios_deps.scan_binary(ipa_path)
746
+ result["findings"].extend(sca_findings)
747
+ result["rule_defs"].extend(sca_rule_defs)
748
+ sca_cov = _sca_coverage(sca_stats)
749
+ console.print(
750
+ f"[dim]SCA: checked {sca_stats['unique_dependencies_checked']} unique "
751
+ f"embedded framework(s), found {sca_stats['cves_found']} known CVE(s) across "
752
+ f"{sca_stats['vulnerable_dependencies']} framework(s).[/dim]"
753
+ )
754
+ _warn_github_advisory_truncated(sca_stats)
755
+ except Exception as e:
756
+ console.print(f"[dim]SCA dependency check failed (continuing without it): {e}[/dim]")
757
+
758
+ return result["findings"], result["rule_defs"], {"encrypted": bool(result.get("encrypted")), "sca_coverage": sca_cov}
759
+
760
+
761
+ def _run_split_bundle_scan(bundle_path, max_mem, override_config=None, force=False):
762
+ """Unpack a .apkm/.xapk/.apks and hand the members to _run_local_scan. None on hard failure, else (findings, rule_defs)."""
763
+ tmp_dir = tempfile.mkdtemp(prefix="narvy-bundle-")
764
+ try:
765
+ try:
766
+ bundle = split_bundle.extract_for_scan(bundle_path, tmp_dir)
767
+ except split_bundle.SplitBundleError as e:
768
+ console.print(f"\n[bold red]{e}[/bold red]\n")
769
+ return None
770
+
771
+ for style, text in split_bundle.summary_lines(bundle):
772
+ # markup=False is load-bearing: these lines embed bracketed package
773
+ # names that rich would otherwise parse as markup and eat.
774
+ console.print(text, style=style, markup=False)
775
+
776
+ return _run_local_scan(
777
+ bundle.base_apk_path,
778
+ max_mem,
779
+ override_config=override_config,
780
+ force=force,
781
+ native_apk_path=bundle.native_apk_path or bundle.base_apk_path,
782
+ extra_dex_inputs=bundle.feature_apk_paths,
783
+ )
784
+ finally:
785
+ shutil.rmtree(tmp_dir, ignore_errors=True)
786
+
787
+
788
+ _SEV_ORDER = {"CRITICAL": 0, "HIGH": 1, "MEDIUM": 2, "LOW": 3, "INFO": 4}
789
+ _SEV_STYLE = {"CRITICAL": "red", "HIGH": "yellow", "MEDIUM": "blue",
790
+ "LOW": "cyan", "INFO": "dim"}
791
+ _CONF_STYLE = {"LOW": "dim", "MEDIUM": "white", "HIGH": "bold"}
792
+
793
+
794
+ def _finding_location_text(finding: Dict[str, Any]) -> str:
795
+ """'File:Line', or 'File (symbol)' for a binary-mode finding whose compiled
796
+ target has no line-number table and carries a `location_symbol` instead."""
797
+ # file_path and location_symbol carry attacker-controlled text from the
798
+ # scanned target: escape before rich parses it as markup.
799
+ location_symbol = finding.get('location_symbol')
800
+ file_path = rich_escape(str(finding.get('file_path', '')))
801
+ if location_symbol:
802
+ return f"{file_path} [dim]({rich_escape(str(location_symbol))})[/dim]"
803
+ return f"{file_path}:{finding.get('line', '?')}"
804
+
805
+
806
+ def _details_text(value: Any) -> str:
807
+ """Coerce a `details` field to a string; a semgrep rule may declare
808
+ metadata such as `cwe:` as a YAML list rather than a string."""
809
+ if isinstance(value, list):
810
+ return ", ".join(str(v).strip() for v in value if v)
811
+ return str(value or "").strip()
812
+
813
+
814
+ def _render_finding_panel(finding: Dict[str, Any]) -> Panel:
815
+ """One finding: location, description, recommendation and CWE/MASVS refs."""
816
+ severity = str(finding.get('severity') or 'UNKNOWN').upper()
817
+ sev_style = _SEV_STYLE.get(severity, "white")
818
+ confidence = finding.get('confidence', 'MEDIUM')
819
+ conf_style = _CONF_STYLE.get(confidence, "white")
820
+ details = finding.get('details') or {}
821
+ # description/recommendation embed raw content from the scanned target.
822
+ description = rich_escape(_details_text(details.get('description')))
823
+ recommendation = rich_escape(_details_text(details.get('recommendation')))
824
+ cwe = rich_escape(_details_text(details.get('cwe')))
825
+ masvs = rich_escape(_details_text(details.get('masvs')))
826
+
827
+ lines = [
828
+ f"[dim]Location:[/dim] {_finding_location_text(finding)}",
829
+ f"[dim]Confidence:[/dim] [{conf_style}]{confidence}[/{conf_style}]",
830
+ ]
831
+ if description:
832
+ lines.append("")
833
+ lines.append(description)
834
+ if recommendation and recommendation != description:
835
+ lines.append("")
836
+ lines.append(f"[bold]Recommendation:[/bold] {recommendation}")
837
+ ref_bits = [b for b in (cwe, masvs) if b]
838
+ if ref_bits:
839
+ lines.append("")
840
+ lines.append(f"[dim]{' · '.join(ref_bits)}[/dim]")
841
+
842
+ body = "\n".join(lines)
843
+ rule_name = rich_escape(str(finding.get('name') or finding.get('rule_id') or 'Finding'))
844
+ title = f"[{sev_style}]{severity}[/{sev_style}] {rule_name}"
845
+ return Panel(body, title=title, title_align="left", border_style=sev_style,
846
+ box=rich_box.ROUNDED, padding=(0, 1))
847
+
848
+
849
+ def _print_findings_detail(console: Console, all_findings: List[Dict[str, Any]]) -> None:
850
+ """Per-finding detail, grouped by severity, most severe first."""
851
+ known_order = ("CRITICAL", "HIGH", "MEDIUM", "LOW", "INFO")
852
+ for severity in known_order:
853
+ group = [f for f in all_findings if str(f.get('severity') or '').upper() == severity]
854
+ if not group:
855
+ continue
856
+ sev_style = _SEV_STYLE.get(severity, "white")
857
+ console.print(f"\n[bold {sev_style}]{severity} ({len(group)})[/bold {sev_style}]")
858
+ for finding in group:
859
+ console.print(_render_finding_panel(finding))
860
+ leftover = [f for f in all_findings if str(f.get('severity') or '').upper() not in known_order]
861
+ if leftover:
862
+ console.print(f"\n[bold white]OTHER ({len(leftover)})[/bold white]")
863
+ for finding in leftover:
864
+ console.print(_render_finding_panel(finding))
865
+
866
+
867
+ def _ingest_platform_for_scan_mode(scan_mode: str) -> str:
868
+ """Map a scan_mode onto the platform value the ingest endpoint expects."""
869
+ if scan_mode.startswith("ios"):
870
+ return "ios"
871
+ if scan_mode.startswith("web"):
872
+ return "web"
873
+ return "android"
874
+
875
+
876
+ def _submit_results_ingest(all_findings, scan_mode, target_path):
877
+ """POST this scan's results to /api/v1/ingest. Only findings leave the
878
+ machine. Raises SystemExit(1) on any auth, transport or server failure."""
879
+ creds = auth.load_credentials()
880
+ if not creds:
881
+ console.print("[bold red]Not logged in.[/bold red] Run `narvy login` first, or drop --upload for a local-only scan.")
882
+ raise SystemExit(1)
883
+
884
+ platform = _ingest_platform_for_scan_mode(scan_mode)
885
+ app_name = os.path.basename(os.path.normpath(target_path))
886
+ app_name = re.sub(r"\.(zip|apk|aab|ipa|xapk|apkm|apks)$", "", app_name, flags=re.IGNORECASE) or app_name
887
+
888
+ console.print(
889
+ f"[cyan]Uploading {len(all_findings)} finding(s) to {creds['email']} "
890
+ f"for server-side curation...[/cyan]"
891
+ )
892
+ try:
893
+ status, data = uploader.ingest_results(
894
+ creds["token"], app_name=app_name, platform=platform,
895
+ scan_mode=scan_mode, findings=all_findings,
896
+ )
897
+ except Exception as e:
898
+ console.print(f"[bold red]Upload failed:[/bold red] {e}")
899
+ raise SystemExit(1)
900
+
901
+ if status not in (200, 201):
902
+ asset_msg = _format_asset_limit_error(data) if status == 402 else None
903
+ if asset_msg:
904
+ console.print(f"[bold red]{asset_msg}[/bold red]")
905
+ raise SystemExit(1)
906
+ error_msg = data.get("error") or data if isinstance(data, dict) else data
907
+ console.print(f"[bold red]Ingest failed ({status}):[/bold red] {error_msg}")
908
+ if isinstance(data, dict) and data.get("message"):
909
+ console.print(f"[dim]{data['message']}[/dim]")
910
+ raise SystemExit(1)
911
+
912
+ scan_id = data.get("scan_id")
913
+ received = data.get("cli_findings_received", len(all_findings))
914
+ normalized = data.get("normalized", "?")
915
+ total_detected = data.get("total_detected", "?")
916
+ actionable = data.get("actionable", "?")
917
+ hidden = data.get("hidden", "?")
918
+ dropped = data.get("dropped", 0)
919
+
920
+ console.print(f"[bold green]Results ingested.[/bold green] scan_id={scan_id}")
921
+ console.print(
922
+ f"[dim]CLI detected {received} -> Narvy cloud curated: {total_detected} distinct "
923
+ f"after dedup, {actionable} actionable, {hidden} hidden by default view.[/dim]"
924
+ )
925
+ if dropped:
926
+ console.print(
927
+ f"[bold yellow]{dropped} finding(s) were DROPPED server-side during "
928
+ f"normalization (unidentifiable/malformed) - they are NOT in the "
929
+ f"dashboard. See the server response for details.[/bold yellow]"
930
+ )
931
+ for d in (data.get("dropped_details") or [])[:10]:
932
+ console.print(f" [yellow]- idx {d.get('index')}: {d.get('reason')}[/yellow]")
933
+ console.print("Track it at [link=https://narvy.io]narvy.io[/link] under your dashboard.")
934
+
935
+
936
+ def _scan_to_json(all_findings, rules, scan_mode, target_path, encrypted, scan_meta=None):
937
+ """Flat machine-readable JSON for `narvy scan --output json`."""
938
+ by_sev: Dict[str, int] = {}
939
+ out_findings: List[Dict[str, Any]] = []
940
+ for f in all_findings:
941
+ sev = str(f.get("severity", "") or "").upper()
942
+ by_sev[sev] = by_sev.get(sev, 0) + 1
943
+ details = f.get("details") if isinstance(f.get("details"), dict) else {}
944
+ out_findings.append({
945
+ "rule_id": f.get("rule_id"),
946
+ "name": f.get("name"),
947
+ "severity": sev,
948
+ "confidence": f.get("confidence", "MEDIUM"),
949
+ "file_path": f.get("file_path"),
950
+ "line": f.get("line"),
951
+ "location_symbol": f.get("location_symbol"),
952
+ "engine": f.get("engine"),
953
+ "description": details.get("description"),
954
+ "recommendation": details.get("recommendation"),
955
+ "cwe": details.get("cwe"),
956
+ "masvs": details.get("masvs"),
957
+ })
958
+ summary = {
959
+ "total_findings": len(all_findings),
960
+ "by_severity": by_sev,
961
+ }
962
+ sca_cov = (scan_meta or {}).get("sca_coverage")
963
+ if sca_cov is not None:
964
+ summary["sca_coverage"] = sca_cov
965
+ return {
966
+ "tool": "Narvy CLI",
967
+ "version": __version__,
968
+ "scan_mode": scan_mode,
969
+ "target": os.path.basename(target_path),
970
+ "encrypted": bool(encrypted),
971
+ "summary": summary,
972
+ "findings": out_findings,
973
+ }
974
+
975
+
976
+ def cmd_scan(args):
977
+ target_path = args.apk_path
978
+ if getattr(args, "upload_binary", False):
979
+ args.upload = True
980
+ if not os.path.exists(target_path):
981
+ console.print(f"[bold red]Error:[/bold red] File not found at {target_path}")
982
+ raise SystemExit(1)
983
+
984
+ try:
985
+ scan_mode = _detect_scan_mode(target_path)
986
+ except UnsupportedScanTarget as e:
987
+ console.print(f"[bold red]Error:[/bold red] {e}")
988
+ raise SystemExit(1)
989
+
990
+ # --- Pre-flight checks: fail fast BEFORE the (multi-minute) scan runs. ---
991
+
992
+ # (2) Auth gate. A logged-out user asking to upload should not wait for a
993
+ # full local scan only to be told to log in at the very end.
994
+ if args.upload or getattr(args, "upload_binary", False):
995
+ if not auth.load_credentials():
996
+ console.print("[bold red]Not logged in.[/bold red] Run `narvy login` first.")
997
+ raise SystemExit(1)
998
+
999
+ # --max-mem goes straight to JADX as -Xmx; validate before a bad value leaks a raw JVM error.
1000
+ if scan_mode in ("android", "android-bundle"):
1001
+ if not _MAX_MEM_RE.match(str(args.max_mem or "")):
1002
+ console.print(
1003
+ f"[bold red]Invalid --max-mem value '{args.max_mem}'.[/bold red] "
1004
+ "Expected a JADX heap size like '4g', '2048m' or '512k' "
1005
+ "(digits optionally followed by k/m/g)."
1006
+ )
1007
+ raise SystemExit(1)
1008
+
1009
+ # (7) Output writability. Check the --file destination up front so an
1010
+ # unwritable path fails now, not after the whole scan completes.
1011
+ if args.output in ("sarif", "json") and args.file != "-":
1012
+ _default_out = "narvy-results.sarif" if args.output == "sarif" else "narvy-results.json"
1013
+ _preflight_out = args.file or _default_out
1014
+ _werr = _preflight_output_writable(_preflight_out)
1015
+ if _werr:
1016
+ console.print(f"[bold red]Cannot write {args.output.upper()} output to {_preflight_out}: {_werr}.[/bold red]")
1017
+ console.print("[dim]Check the parent directory exists and is writable, or pick a different --file path.[/dim]")
1018
+ raise SystemExit(1)
1019
+
1020
+ # Android and iOS-binary paths only: iOS source mode classifies vendored
1021
+ # code by directory convention instead.
1022
+ override_config = load_scope_config(target_path)
1023
+ if override_config:
1024
+ console.print(f"[dim]Loaded scope overrides from {override_config.source_path}: "
1025
+ f"{len(override_config.own)} 'own' pattern(s), "
1026
+ f"{len(override_config.vendor)} 'vendor' pattern(s).[/dim]")
1027
+
1028
+ if scan_mode == "ios-source":
1029
+ result = _run_ios_source_scan(target_path)
1030
+ elif scan_mode == "ios-binary":
1031
+ result = _run_ios_binary_scan(target_path, override_config=override_config)
1032
+ elif scan_mode == "android-source":
1033
+ result = _run_android_source_scan(target_path, override_config=override_config)
1034
+ elif scan_mode == "web-source":
1035
+ result = _run_web_source_scan(target_path)
1036
+ elif scan_mode == "android-bundle":
1037
+ result = _run_split_bundle_scan(target_path, args.max_mem,
1038
+ override_config=override_config,
1039
+ force=getattr(args, "force", False))
1040
+ else:
1041
+ result = _run_local_scan(target_path, args.max_mem,
1042
+ override_config=override_config,
1043
+ force=getattr(args, "force", False))
1044
+
1045
+ if result is None:
1046
+ raise SystemExit(1)
1047
+ if len(result) == 3:
1048
+ all_findings, rules, scan_meta = result
1049
+ else:
1050
+ all_findings, rules = result
1051
+ scan_meta = {}
1052
+ encrypted_scan = bool(scan_meta.get("encrypted"))
1053
+
1054
+ if args.output in ('sarif', 'json'):
1055
+ # Written to a file, never stdout: progress notes would interleave.
1056
+ if args.output == 'sarif':
1057
+ report = generate_sarif_report(
1058
+ all_findings, rules,
1059
+ encrypted=encrypted_scan,
1060
+ encrypted_artifact_uri=os.path.basename(target_path) if encrypted_scan else None,
1061
+ )
1062
+ out_file = args.file or "narvy-results.sarif"
1063
+ label = "SARIF report"
1064
+ else:
1065
+ report = _scan_to_json(all_findings, rules, scan_mode, target_path, encrypted_scan, scan_meta=scan_meta)
1066
+ out_file = args.file or "narvy-results.json"
1067
+ label = "JSON report"
1068
+ # `--file -` streams machine output to stdout (progress/notes go to stderr) so `... -f - | jq` works.
1069
+ if args.file == "-":
1070
+ sys.stdout.write(json.dumps(report, indent=2) + "\n")
1071
+ sys.stdout.flush()
1072
+ else:
1073
+ try:
1074
+ with open(out_file, 'w') as f:
1075
+ json.dump(report, f, indent=2)
1076
+ except OSError as e:
1077
+ console.print(f"\n[bold red]Could not write {label} to {out_file}: {e}[/bold red]")
1078
+ console.print("[dim]Check the parent directory exists and is writable, or pick a different --file path.[/dim]")
1079
+ raise SystemExit(1)
1080
+ console.print(f"\n[bold green]{label} saved to {out_file}[/bold green]")
1081
+ else:
1082
+ console.print("\n[bold]--- Local Analysis Complete ---[/bold]")
1083
+ if not all_findings:
1084
+ if encrypted_scan:
1085
+ console.print(
1086
+ "[bold yellow]No issues found among what could be analyzed on this "
1087
+ "FairPlay-encrypted binary (see the encryption notice above) - this is "
1088
+ "NOT the same as a clean bill of health.[/bold yellow]"
1089
+ )
1090
+ else:
1091
+ console.print("[bold green]No issues found based on the current rule set.[/bold green]")
1092
+ else:
1093
+ console.print(f"[bold red]Found {len(all_findings)} potential issues:[/bold red]")
1094
+ table = Table(title="Vulnerability Summary")
1095
+ table.add_column("Severity", justify="center", style="bold")
1096
+ table.add_column("Rule Name", style="cyan")
1097
+ table.add_column("File", style="magenta")
1098
+ table.add_column("Line", justify="right", style="dim")
1099
+ table.add_column("Confidence", justify="center", style="dim")
1100
+ for finding in sorted(all_findings, key=lambda x: _SEV_ORDER.get(x['severity'], 5)):
1101
+ sev_style = _SEV_STYLE.get(finding['severity'], "white")
1102
+ confidence = finding.get('confidence', 'MEDIUM')
1103
+ conf_style = _CONF_STYLE.get(confidence, "white")
1104
+ # A compiled binary has no line-number table, so `line` there is
1105
+ # a placeholder kept only for SARIF.
1106
+ line_cell = "-" if finding.get('location_symbol') else str(finding.get('line', '?'))
1107
+ table.add_row(
1108
+ f"[{sev_style}]{finding['severity']}[/{sev_style}]",
1109
+ rich_escape(str(finding['name'])), rich_escape(str(finding['file_path'])), line_cell,
1110
+ f"[{conf_style}]{confidence}[/{conf_style}]",
1111
+ )
1112
+ out_console.print(table)
1113
+ console.print()
1114
+ console.rule("[bold]Finding Detail[/bold]")
1115
+ _print_findings_detail(console, all_findings)
1116
+ console.print()
1117
+ if scan_mode in ("android", "android-bundle", "android-source",
1118
+ "ios-binary", "ios-source"):
1119
+ console.print(
1120
+ "[dim]Tip: seeing your own code flagged as third-party, or a vendor SDK "
1121
+ "flagged as yours? Add a .narvy-scope.yml to this repo to correct "
1122
+ "it - see the '.narvy-scope.yml' section of the CLI's SETUP.md[/dim]"
1123
+ )
1124
+
1125
+ if args.upload and not getattr(args, "upload_binary", False):
1126
+ _submit_results_ingest(all_findings, scan_mode, target_path)
1127
+ elif args.upload:
1128
+ # Legacy --upload-binary: POST the binary and let the server re-scan it.
1129
+ # upload_scan() opens the path in binary mode, so refuse a directory.
1130
+ if scan_mode in ("ios-source", "android-source", "web-source"):
1131
+ platform_label = {"ios-source": "iOS", "android-source": "Android", "web-source": "web/backend"}[scan_mode]
1132
+ console.print(f"[bold red]--upload-binary isn't supported for {platform_label} source-directory scans[/bold red] (the binary re-scan API expects a single .apk/.aab/.ipa file). Use plain [bold]--upload[/bold] instead - it submits this scan's RESULTS for server-side curation and DOES support source directories.")
1133
+ raise SystemExit(1)
1134
+ creds = auth.load_credentials()
1135
+ if not creds:
1136
+ console.print("[bold red]Not logged in.[/bold red] Run `narvy login` first, or drop --upload for a local-only scan.")
1137
+ raise SystemExit(1)
1138
+ # The server does its own base-module extraction, so the original
1139
+ # bundle is what gets sent. `.apks` is not in its allowlist.
1140
+ if scan_mode == "android-bundle" and target_path.lower().endswith(".apks"):
1141
+ console.print(
1142
+ "[bold red]--upload isn't supported for .apks bundles[/bold red] "
1143
+ "(the server accepts .apk, .aab, .apkm and .xapk). The local scan above "
1144
+ "already ran and its results are shown; to get a server-side scan too, "
1145
+ "extract the app module yourself (`unzip -j app.apks splits/base-master.apk`) "
1146
+ "and upload that .apk."
1147
+ )
1148
+ raise SystemExit(1)
1149
+ upload_platform = "android" if scan_mode in ("android", "android-bundle") else "ios"
1150
+ confirm_upload = getattr(args, "confirm_upload", False)
1151
+ console.print(f"\n[cyan]Uploading {os.path.basename(target_path)} to Narvy for the full server-side scan ({creds['email']}, {creds['plan']})...[/cyan]")
1152
+ status, data = uploader.upload_scan(
1153
+ target_path, creds["token"], platform=upload_platform, analysis_type="sast",
1154
+ confirm_warning=confirm_upload,
1155
+ )
1156
+ # 409 means the triage severity is 'warn', which a resubmit with
1157
+ # confirm_warning=true clears. One retry at most.
1158
+ if status == 409 and not confirm_upload and sys.stdin.isatty() and sys.stdout.isatty():
1159
+ triage = data.get('triage') or {}
1160
+ severity = triage.get('severity', '?')
1161
+ score = triage.get('score', '?')
1162
+ console.print(f"[bold yellow]Triage verdict:[/bold yellow] severity={severity} score={score}")
1163
+ findings = triage.get('findings') or []
1164
+ if findings:
1165
+ console.print("[bold yellow]Triage findings:[/bold yellow]")
1166
+ for finding in findings:
1167
+ console.print(f" - {finding}")
1168
+ try:
1169
+ proceed = Confirm.ask("Upload anyway?", default=False)
1170
+ except (EOFError, KeyboardInterrupt):
1171
+ proceed = False
1172
+ if proceed:
1173
+ status, data = uploader.upload_scan(
1174
+ target_path, creds["token"], platform=upload_platform, analysis_type="sast",
1175
+ confirm_warning=True,
1176
+ )
1177
+ confirm_upload = True
1178
+ if status not in (200, 202):
1179
+ asset_msg = _format_asset_limit_error(data) if status == 402 else None
1180
+ if asset_msg:
1181
+ console.print(f"[bold red]{asset_msg}[/bold red]")
1182
+ raise SystemExit(1)
1183
+ # The malware-triage gate returns a 'triage' dict alongside the
1184
+ # top-level error on 400 (refuse) and 409 (warn).
1185
+ error_msg = data.get('error') or data.get('warning') or data
1186
+ console.print(f"[bold red]Upload failed ({status}):[/bold red] {error_msg}")
1187
+ triage = data.get('triage')
1188
+ if isinstance(triage, dict):
1189
+ severity = triage.get('severity', '?')
1190
+ score = triage.get('score', '?')
1191
+ console.print(f"[bold yellow]Triage verdict:[/bold yellow] severity={severity} score={score}")
1192
+ bundle_id = triage.get('bundle_id')
1193
+ if bundle_id:
1194
+ console.print(
1195
+ f"[dim]bundle_id={bundle_id} "
1196
+ f"signing_identity={triage.get('signing_identity')}[/dim]"
1197
+ )
1198
+ findings = triage.get('findings') or []
1199
+ if findings:
1200
+ console.print("[bold yellow]Triage findings:[/bold yellow]")
1201
+ for finding in findings:
1202
+ console.print(f" - {finding}")
1203
+ if status == 409 and not confirm_upload:
1204
+ console.print(
1205
+ "[dim]This was a warning, not a hard refusal - skip the interactive "
1206
+ "confirmation next time (CI/non-interactive use) with "
1207
+ "[bold]--confirm-upload[/bold].[/dim]"
1208
+ )
1209
+ else:
1210
+ hint = data.get('hint')
1211
+ if hint:
1212
+ console.print(f"[dim]{hint}[/dim]")
1213
+ raise SystemExit(1)
1214
+ scan_id = data.get('scan_id')
1215
+ console.print(f"[bold green]Server scan submitted.[/bold green] scan_id={scan_id}")
1216
+ console.print(f"Track it at [link=https://narvy.io]narvy.io[/link] under your dashboard.")
1217
+
1218
+ # Informational only: the response carries no app id to sync overrides to.
1219
+ if override_config:
1220
+ console.print(
1221
+ "[dim]Note: you have local scope overrides in "
1222
+ f"{override_config.source_path} (used for this local pre-check "
1223
+ "only). The server-side scan you just submitted runs "
1224
+ "independently and does not read this file - if you want the "
1225
+ "same own/vendor classification applied there, set it on this "
1226
+ "app's Narvy dashboard page too.[/dim]"
1227
+ )
1228
+ else:
1229
+ console.print("\n[bold yellow]This was a local-only pre-check ([bold]not[/bold] linked to your account).[/bold yellow]")
1230
+ if scan_mode == "android":
1231
+ console.print(f"Scanned with {len(rules)} local rules. On the same app, Narvy Cloud adds deep decompilation, ML triage and MASVS mapping.")
1232
+ elif scan_mode == "android-source":
1233
+ console.print(f"Scanned with {len(rules)} local rules against the raw source checkout. Narvy Cloud runs deeper polyglot SAST against your source, with MASVS mapping and ML triage.")
1234
+ elif scan_mode == "web-source":
1235
+ console.print(f"Scanned with {len(rules)} local rules against the raw source checkout. Narvy Cloud runs our full polyglot web SAST engine (more languages/rule packs, SDLC/compliance mapping, ML triage).")
1236
+ elif scan_mode == "ios-binary":
1237
+ console.print(f"Scanned with {len(rules)} local rules against the compiled binary only. Narvy Cloud runs full iOS SAST (deeper Mach-O/ObjC/Swift analysis, MASVS mapping, ML triage) plus DAST.")
1238
+ else:
1239
+ console.print(f"Scanned with {len(rules)} local rules. Narvy Cloud runs our full iOS source SAST engine (deeper Swift/ObjC semantic analysis, MASVS mapping, ML triage).")
1240
+ console.print("[dim]This is a flat, unfiltered list (one row per rule hit). The cloud groups, de-duplicates and false-positive-filters the same findings and shows a severity-ranked default view, so its headline count for the same code is often different - and can be smaller - by design, not because it found less.[/dim]")
1241
+ console.print("Run `narvy login` then re-run with [bold]--upload[/bold] for the full server-side scan (compliance mapping, ML triage, dashboard history) - [link=https://narvy.io]narvy.io[/link]")
1242
+
1243
+ # Must stay last so failing the build never swallows the report/upload.
1244
+ # A coverage gap = a pass that couldn't read the target, where exit 0 would falsely claim clean.
1245
+ coverage_gaps = []
1246
+ if getattr(args, "fail_on", None):
1247
+ if encrypted_scan:
1248
+ coverage_gaps.append(
1249
+ "This IPA is FairPlay-encrypted, so the rules that read code and string "
1250
+ "constants were structurally blind to most of the binary (see the "
1251
+ "encryption notice above)."
1252
+ )
1253
+ if scan_mode in ("android", "android-bundle", "android-source",
1254
+ "ios-source", "web-source"):
1255
+ if not semgrep_engine.is_available():
1256
+ coverage_gaps.append(
1257
+ "The deep structural (Semgrep) pass did not run at all - semgrep is not "
1258
+ "on PATH. Install it (`pip install semgrep`) for the code analysis this "
1259
+ "scan mode relies on."
1260
+ )
1261
+ else:
1262
+ _gap_note = semgrep_engine.degraded_coverage_note()
1263
+ if _gap_note:
1264
+ coverage_gaps.append(_gap_note)
1265
+ _exit_on_fail_on(all_findings, getattr(args, "fail_on", None),
1266
+ coverage_gaps=coverage_gaps)
1267
+
1268
+
1269
+ _UPLOAD_ALL_TARGET_EXTS = (".apk", ".aab", ".apkm", ".xapk", ".ipa")
1270
+ # .apks is excluded from the walk entirely: the server does not accept it.
1271
+ _UPLOAD_ALL_SKIP_DIR_NAMES = frozenset({
1272
+ ".git", ".gradle", ".idea", ".vscode", ".vs", "build", "out", ".cxx",
1273
+ "node_modules", "Pods", "Carthage", "DerivedData", ".build", "venv",
1274
+ ".venv", "__pycache__", ".narvy",
1275
+ })
1276
+
1277
+
1278
+ def _find_upload_targets(directory, recursive=True):
1279
+ """Sorted scan targets under `directory`, plus a count of .apks skipped."""
1280
+ targets = []
1281
+ apks_skipped = 0
1282
+ if recursive:
1283
+ for root, dirs, files in os.walk(directory):
1284
+ dirs[:] = [d for d in dirs if d not in _UPLOAD_ALL_SKIP_DIR_NAMES
1285
+ and not d.startswith(".")]
1286
+ for name in files:
1287
+ ext = os.path.splitext(name)[1].lower()
1288
+ if ext in _UPLOAD_ALL_TARGET_EXTS:
1289
+ targets.append(os.path.join(root, name))
1290
+ elif ext == ".apks":
1291
+ apks_skipped += 1
1292
+ else:
1293
+ for name in sorted(os.listdir(directory)):
1294
+ full = os.path.join(directory, name)
1295
+ if not os.path.isfile(full):
1296
+ continue
1297
+ ext = os.path.splitext(name)[1].lower()
1298
+ if ext in _UPLOAD_ALL_TARGET_EXTS:
1299
+ targets.append(full)
1300
+ elif ext == ".apks":
1301
+ apks_skipped += 1
1302
+ return sorted(targets), apks_skipped
1303
+
1304
+
1305
+ def cmd_upload_all(args):
1306
+ directory = args.directory
1307
+ if not os.path.isdir(directory):
1308
+ console.print(f"[bold red]Error:[/bold red] {directory} is not a directory.")
1309
+ raise SystemExit(1)
1310
+
1311
+ creds = auth.load_credentials()
1312
+ if not creds:
1313
+ console.print("[bold red]Not logged in.[/bold red] Run `narvy login` first, or drop `upload-all` for local-only scanning of each app individually.")
1314
+ raise SystemExit(1)
1315
+
1316
+ targets, apks_skipped = _find_upload_targets(directory, recursive=not args.no_recursive)
1317
+ if apks_skipped:
1318
+ console.print(
1319
+ f"[dim]Skipped {apks_skipped} .apks bundle(s) - upload-all only submits "
1320
+ ".apk/.aab/.apkm/.xapk/.ipa (the server doesn't accept raw bundletool "
1321
+ ".apks). Extract the app module first (`unzip -j app.apks "
1322
+ "splits/base-master.apk`) to include it.[/dim]"
1323
+ )
1324
+ if not targets:
1325
+ console.print(
1326
+ f"[bold yellow]No .apk/.aab/.apkm/.xapk/.ipa found under {directory}"
1327
+ f"{'' if not args.no_recursive else ' (top level only, --no-recursive set)'}."
1328
+ "[/bold yellow]"
1329
+ )
1330
+ return
1331
+
1332
+ console.print(
1333
+ f"[cyan]Found {len(targets)} target(s) under {directory}. "
1334
+ f"Scanning and uploading to {creds['email']} ({creds['plan']})...[/cyan]\n"
1335
+ )
1336
+
1337
+ succeeded, failed = [], []
1338
+ for i, target in enumerate(targets, start=1):
1339
+ console.rule(f"[bold]{i}/{len(targets)}: {os.path.relpath(target, directory)}[/bold]")
1340
+ scan_args = argparse.Namespace(
1341
+ apk_path=target, max_mem=args.max_mem, force=args.force,
1342
+ upload=True, output="console", file="narvy-results.sarif",
1343
+ confirm_upload=getattr(args, "confirm_upload", False),
1344
+ )
1345
+ try:
1346
+ cmd_scan(scan_args)
1347
+ except SystemExit as e:
1348
+ if e.code not in (0, None):
1349
+ failed.append(target)
1350
+ continue
1351
+ succeeded.append(target)
1352
+ console.print()
1353
+
1354
+ console.rule("[bold]upload-all summary[/bold]")
1355
+ console.print(f"[bold green]{len(succeeded)} succeeded[/bold green], "
1356
+ f"[bold red]{len(failed)} failed[/bold red] out of {len(targets)}.")
1357
+ if failed:
1358
+ console.print("[bold red]Failed:[/bold red]")
1359
+ for f in failed:
1360
+ console.print(f" - {os.path.relpath(f, directory)}")
1361
+ raise SystemExit(1)
1362
+
1363
+
1364
+ def _write_or_print(output_text, output_path):
1365
+ if output_path and output_path != "-":
1366
+ try:
1367
+ Path(output_path).write_text(output_text)
1368
+ except OSError as e:
1369
+ console.print(f"[bold red]Could not write results to {output_path}: {e}[/bold red]")
1370
+ console.print("[dim]Check the parent directory exists and is writable, or pick a different --output-file path.[/dim]")
1371
+ raise SystemExit(1)
1372
+ console.print(f"[bold green]Results written to {output_path}[/bold green]")
1373
+ else:
1374
+ print(output_text)
1375
+
1376
+
1377
+ def _exit_on_fail_on(findings, fail_on, get_severity=lambda f: f.get("severity", "info"),
1378
+ coverage_gaps=None):
1379
+ if not fail_on:
1380
+ return
1381
+ threshold = _SEVERITY_LEVEL.get(fail_on, 0) if fail_on != "any" else 1
1382
+ # Severities arrive in mixed case across the scan paths, so normalise before
1383
+ # ranking. One the map cannot rank fails the gate closed.
1384
+ breaching = []
1385
+ unrankable = {}
1386
+ for f in findings:
1387
+ raw = get_severity(f)
1388
+ s = "" if raw is None else str(raw).strip().lower()
1389
+ level = _SEVERITY_LEVEL.get(s)
1390
+ if level is None:
1391
+ unrankable[s or "(empty)"] = unrankable.get(s or "(empty)", 0) + 1
1392
+ elif level >= threshold:
1393
+ breaching.append(f)
1394
+ if unrankable:
1395
+ detail = ", ".join(f"{n} x '{s}'" for s, n in sorted(unrankable.items()))
1396
+ console.print(
1397
+ f"\n[bold red]Failing this build: --fail-on {fail_on} was asked to gate on "
1398
+ f"{sum(unrankable.values())} finding(s) whose severity this CLI cannot rank "
1399
+ f"({detail}).[/bold red]"
1400
+ )
1401
+ console.print(
1402
+ "[dim]Known values are critical/high/medium/low/info. Rather than treat an "
1403
+ "unrecognised severity as harmless (which is what silently passes a build), "
1404
+ "the gate fails. Please report this output - it means a scanner path is "
1405
+ "emitting a severity the CLI has never seen.[/dim]"
1406
+ )
1407
+ raise SystemExit(1)
1408
+ # Exit 2, not 1, so a pipeline can tell "the gate tripped" from "the gate
1409
+ # could not be run".
1410
+ if coverage_gaps and not breaching:
1411
+ console.print(
1412
+ f"\n[bold red]Cannot pass this build: --fail-on {fail_on} found nothing at or "
1413
+ f"above that severity, but this scan did not have full coverage of the "
1414
+ f"target.[/bold red]"
1415
+ )
1416
+ for gap in coverage_gaps:
1417
+ console.print(f"[yellow] - {gap}[/yellow]")
1418
+ console.print(
1419
+ "[dim]0 findings from a pass that did not run means UNKNOWN, not clean, so the "
1420
+ "gate has nothing to certify. Fix the gap above and re-run, or drop --fail-on "
1421
+ "if you want this scan as a log rather than a gate.[/dim]"
1422
+ )
1423
+ raise SystemExit(2)
1424
+ if not breaching:
1425
+ return
1426
+ counts = {}
1427
+ for f in breaching:
1428
+ s = str(get_severity(f)).strip().lower()
1429
+ counts[s] = counts.get(s, 0) + 1
1430
+ detail = ", ".join(f"{counts[s]} {s}"
1431
+ for s in ("critical", "high", "medium", "low", "info")
1432
+ if counts.get(s))
1433
+ console.print(f"\n[bold red]Failing this build: --fail-on {fail_on} "
1434
+ f"and the scan found {detail}.[/bold red]")
1435
+ raise SystemExit(1)
1436
+
1437
+
1438
+ def _distinct_rules_from_results(results):
1439
+ """tool.driver.rules descriptors for the distinct ruleIds in `results`, in insertion order.
1440
+ SARIF consumers resolve results[].ruleId against tool.driver.rules[].id, so every referenced id must be declared."""
1441
+ rules = []
1442
+ seen = set()
1443
+ for r in results:
1444
+ rid = r.get("ruleId")
1445
+ if rid and rid not in seen:
1446
+ seen.add(rid)
1447
+ rules.append({"id": rid})
1448
+ return rules
1449
+
1450
+
1451
+ def _web_to_sarif(findings):
1452
+ results = [{
1453
+ "ruleId": f.get("template_id", "web-finding"),
1454
+ "message": {"text": f.get("title", f.get("description", ""))},
1455
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": f.get("url", "")}}}],
1456
+ } for f in findings]
1457
+ return {
1458
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
1459
+ "version": "2.1.0",
1460
+ "runs": [{
1461
+ "tool": {"driver": {"name": "Narvy Web Scanner CLI", "informationUri": "https://narvy.io",
1462
+ "rules": _distinct_rules_from_results(results)}},
1463
+ "results": results,
1464
+ }],
1465
+ }
1466
+
1467
+
1468
+ def cmd_web_scan(args):
1469
+ # nuclei treats a non-positive rate as no rate limiting at all.
1470
+ rate_limit = max(1, min(args.rate_limit, WEB_MAX_RATE_LIMIT))
1471
+
1472
+ active = getattr(args, "active", False)
1473
+ mode_label = "active/dynamic" if active else "passive-only"
1474
+
1475
+ if args.format == "text":
1476
+ console.print(f"\n[bold]Narvy Web Scanner[/bold] (free, local, {mode_label})")
1477
+ console.print(f"Target: {args.url}")
1478
+ console.print(f"Rate limit: {rate_limit} req/s (ceiling {WEB_MAX_RATE_LIMIT}, not overridable)")
1479
+ if active:
1480
+ console.print("[yellow]Active mode sends non-GET probes. Only scan targets you own or are authorized to test.[/yellow]")
1481
+ console.print("")
1482
+
1483
+ try:
1484
+ check_blocklist(args.url)
1485
+ except ScanBlocked:
1486
+ console.print(f"[bold red]ERROR:[/bold red] {REFUSAL_MESSAGE}")
1487
+ raise SystemExit(2)
1488
+ try:
1489
+ web_validate_url(args.url)
1490
+ except SSRFBlocked as e:
1491
+ console.print(f"[bold red]ERROR:[/bold red] {e}")
1492
+ raise SystemExit(2)
1493
+
1494
+ with Progress(console=console, disable=not console.is_terminal) as progress:
1495
+ scan_desc = "active Nuclei scan" if active else "passive Nuclei scan"
1496
+ task = progress.add_task(f"[cyan]Running {scan_desc} (can take several minutes)...", total=None)
1497
+ try:
1498
+ scanner = NucleiScanner()
1499
+ except Exception as e:
1500
+ progress.stop()
1501
+ console.print(f"\n[bold red]Could not obtain nuclei: {e}[/bold red]\n")
1502
+ raise SystemExit(2)
1503
+ result = scanner.scan(target=args.url, rate_limit=rate_limit, active=active)
1504
+ progress.update(task, completed=1, description="[green]Scan complete.")
1505
+
1506
+ if not result.get("success"):
1507
+ console.print(f"[bold red]ERROR: scan failed - {result.get('error', 'unknown error')}[/bold red]")
1508
+ raise SystemExit(2)
1509
+
1510
+ findings = result.get("findings", [])
1511
+
1512
+ if args.format == "json":
1513
+ output = json.dumps(result, indent=2)
1514
+ elif args.format == "sarif":
1515
+ output = json.dumps(_web_to_sarif(findings), indent=2)
1516
+ else:
1517
+ lines = [
1518
+ "=" * 60, f"NARVY WEB SCAN RESULTS ({mode_label})", "=" * 60, "",
1519
+ f"Target: {args.url}",
1520
+ ]
1521
+ scanned_url = result.get("scanned_url")
1522
+ if scanned_url and scanned_url != args.url:
1523
+ lines.append(f"Scanned URL: {scanned_url} (resolved from the redirect chain)")
1524
+ if result.get("redirect_note"):
1525
+ lines.append(f"NOTE: {result['redirect_note']}")
1526
+ lines += [
1527
+ f"Total Findings: {len(findings)}", "",
1528
+ ]
1529
+ for f in findings:
1530
+ lines.append(f"[{f.get('severity', '?').upper()}] {f.get('title', '?')} - {f.get('url', '')}")
1531
+ if not findings:
1532
+ lines.append("No issues found with the passive-only template set.")
1533
+ lines.append("")
1534
+ if active:
1535
+ lines.append("This was an ACTIVE/dynamic scan: the full non-destructive Nuclei")
1536
+ lines.append("template set ran and sent non-GET detection probes (destructive tags")
1537
+ lines.append("dos/fuzzing/brute-force/intrusive excluded). Only run active scans on")
1538
+ lines.append("targets you own or are authorized to test.")
1539
+ else:
1540
+ lines.append("This was a passive-only scan (no injection payloads sent). Re-run with")
1541
+ lines.append("--active to send the full non-destructive dynamic template set. Only run")
1542
+ lines.append("active scans on targets you own or are authorized to test.")
1543
+ output = "\n".join(lines)
1544
+
1545
+ _write_or_print(output, args.output_file)
1546
+ _exit_on_fail_on(findings, args.fail_on)
1547
+
1548
+
1549
+ def _host_to_sarif(findings, coverage="unknown", audit_privilege="unknown"):
1550
+ results = [{
1551
+ "ruleId": f.get("rule_id") or "host-finding",
1552
+ "message": {"text": f.get("title") or f.get("description", "")},
1553
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": "lynis"}}}],
1554
+ } for f in findings]
1555
+ return {
1556
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
1557
+ "version": "2.1.0",
1558
+ "runs": [{
1559
+ "tool": {"driver": {"name": "Narvy Host Audit CLI", "informationUri": "https://narvy.io",
1560
+ "rules": _distinct_rules_from_results(results)}},
1561
+ "properties": {"coverage": coverage, "auditPrivilege": audit_privilege},
1562
+ "results": results,
1563
+ }],
1564
+ }
1565
+
1566
+
1567
+ def _split_host_port(host, port):
1568
+ """Split an inline ``host:port``; an inline port wins. ``[ipv6]:port`` is
1569
+ supported, a bare IPv6 literal is left untouched."""
1570
+ h = (host or "").strip()
1571
+ if h.startswith("["):
1572
+ end = h.find("]")
1573
+ if end != -1:
1574
+ addr, rest = h[1:end], h[end + 1:]
1575
+ if rest.startswith(":") and rest[1:].isdigit():
1576
+ return addr, int(rest[1:])
1577
+ return addr, port
1578
+ return h, port
1579
+ if h.count(":") == 1:
1580
+ left, right = h.rsplit(":", 1)
1581
+ if left and right.isdigit():
1582
+ return left, int(right)
1583
+ return h, port
1584
+
1585
+
1586
+ def cmd_host_audit(args):
1587
+ args.host, args.port = _split_host_port(args.host, args.port)
1588
+ if args.format == "text":
1589
+ console.print(f"\n[bold]Narvy Host Audit[/bold] - {args.user}@{args.host}:{args.port}")
1590
+ console.print("Runs locally, using your own SSH access (~/.ssh/config, ssh-agent, default identity files). "
1591
+ "No credentials are sent to Narvy.\n")
1592
+
1593
+ password = os.environ.get("NARVY_SSH_PASSWORD", "").strip()
1594
+ if not password and getattr(args, "ask_password", False):
1595
+ password = getpass.getpass(f"SSH password for {args.user}@{args.host}: ")
1596
+ if password:
1597
+ from .host.ssh_exec import capture_host_key
1598
+ host_key = capture_host_key(args.host, args.port)
1599
+ if not host_key:
1600
+ console.print(f"[bold red]ERROR: could not fetch the host key for {args.host}:{args.port} to pin it for password auth[/bold red]")
1601
+ raise SystemExit(2)
1602
+ conn = HostConn(
1603
+ hostname=args.host,
1604
+ user=args.user,
1605
+ port=args.port,
1606
+ password=password,
1607
+ known_host_key=host_key,
1608
+ use_ambient_identity=False,
1609
+ )
1610
+ else:
1611
+ conn = HostConn(
1612
+ hostname=args.host,
1613
+ user=args.user,
1614
+ port=args.port,
1615
+ use_ambient_identity=True,
1616
+ )
1617
+
1618
+ start = time.time()
1619
+ with Progress(console=console, disable=not console.is_terminal) as progress:
1620
+ task = progress.add_task("[cyan]Running host audit over SSH (can take a few minutes)...", total=None)
1621
+ try:
1622
+ outcome = audit_host(conn, privilege=args.privilege)
1623
+ except SSHExecError as e:
1624
+ progress.stop()
1625
+ console.print(f"\n[bold red]ERROR: SSH connection failed - {e}[/bold red]\n")
1626
+ raise SystemExit(2)
1627
+ progress.update(task, completed=1, description="[green]Audit complete.")
1628
+ duration = time.time() - start
1629
+
1630
+ if not outcome.ok:
1631
+ console.print(f"[bold red]ERROR: audit failed - {outcome.error}[/bold red]")
1632
+ raise SystemExit(2)
1633
+
1634
+ findings = normalize_host_findings(outcome.findings or [])
1635
+ counts = host_severity_counts(findings)
1636
+
1637
+ meta = outcome.meta or {}
1638
+ audit_privilege = meta.get("audit_privilege", "unknown")
1639
+ coverage = meta.get("coverage", "unknown")
1640
+ scan_engine = meta.get(
1641
+ "scan_engine",
1642
+ "Narvy internal host checks + Lynis (GPL-3.0) hardening baseline")
1643
+
1644
+ result = {
1645
+ "target": f"{args.user}@{args.host}:{args.port}",
1646
+ "duration_seconds": round(duration, 1),
1647
+ "scan_engine": scan_engine,
1648
+ "hardening_index": outcome.hardening_index,
1649
+ "severity_counts": counts,
1650
+ "total_findings": len(findings),
1651
+ "coverage": coverage,
1652
+ "audit_privilege": audit_privilege,
1653
+ "findings": findings,
1654
+ }
1655
+
1656
+ if args.format == "json":
1657
+ output = json.dumps(result, indent=2)
1658
+ elif args.format == "sarif":
1659
+ output = json.dumps(_host_to_sarif(findings, coverage=coverage,
1660
+ audit_privilege=audit_privilege), indent=2)
1661
+ else:
1662
+ lines = [
1663
+ "=" * 60, "NARVY HOST AUDIT RESULTS", "=" * 60, "",
1664
+ f"Target: {result['target']}",
1665
+ f"Duration: {result['duration_seconds']}s",
1666
+ f"Scan engine: {result['scan_engine']}",
1667
+ f"Hardening Index: {result['hardening_index']}",
1668
+ f"Total Findings: {result['total_findings']}",
1669
+ ]
1670
+ if coverage == "partial":
1671
+ lines.append(
1672
+ "Coverage: PARTIAL (non-root audit; root-only CIS checks - deep "
1673
+ "file-permission and many hardening controls - were skipped. Run "
1674
+ "as root or with NOPASSWD sudo for full coverage.)")
1675
+ elif coverage == "full":
1676
+ lines.append(f"Coverage: FULL (privilege: {audit_privilege})")
1677
+ else:
1678
+ lines.append(f"Coverage: {coverage} (privilege: {audit_privilege})")
1679
+ lines += [
1680
+ "",
1681
+ "Severity Distribution:",
1682
+ ]
1683
+ for sev in ["Critical", "High", "Medium", "Low", "Info"]:
1684
+ c = counts.get(sev, 0)
1685
+ if c:
1686
+ lines.append(f" {sev.upper()}: {c}")
1687
+ lines.append("")
1688
+ for f in findings:
1689
+ lines.append(f"[{f.get('severity', '?').upper()}] {f.get('title', '?')}")
1690
+ output = "\n".join(lines)
1691
+
1692
+ _write_or_print(output, args.output_file)
1693
+ _exit_on_fail_on(findings, args.fail_on)
1694
+
1695
+
1696
+ def _cloud_flatten(report):
1697
+ # 'passed_checks' is a list of dicts too and must be skipped by name.
1698
+ skip_keys = {"timestamp", "passed_checks"}
1699
+ findings = []
1700
+ for category, items in report.items():
1701
+ if category in skip_keys or not isinstance(items, list):
1702
+ continue
1703
+ for item in items:
1704
+ if not isinstance(item, dict):
1705
+ continue
1706
+ findings.append({
1707
+ "category": category,
1708
+ "severity": str(item.get("severity", "info")).lower(),
1709
+ "title": item.get("Issue") or item.get("issue") or item.get("Description")
1710
+ or item.get("description") or f"{category} finding",
1711
+ "recommendation": item.get("Recommendation") or item.get("recommendation", ""),
1712
+ "resource": item.get("GroupId") or item.get("BucketName") or item.get("UserName")
1713
+ or item.get("FunctionName") or item.get("Region") or "",
1714
+ })
1715
+ return findings
1716
+
1717
+
1718
+ def _cloud_to_sarif(findings):
1719
+ results = [{
1720
+ "ruleId": f["category"],
1721
+ "message": {"text": f["title"]},
1722
+ "locations": [{"physicalLocation": {"artifactLocation": {"uri": f["resource"] or f["category"]}}}],
1723
+ } for f in findings]
1724
+ return {
1725
+ "$schema": "https://schemastore.azurewebsites.net/schemas/json/sarif-2.1.0-rtm.5.json",
1726
+ "version": "2.1.0",
1727
+ "runs": [{
1728
+ "tool": {"driver": {"name": "Narvy Cloud Scanner CLI", "informationUri": "https://narvy.io",
1729
+ "rules": _distinct_rules_from_results(results)}},
1730
+ "results": results,
1731
+ }],
1732
+ }
1733
+
1734
+
1735
+ def cmd_cloud_scan(args):
1736
+ try:
1737
+ from .cloud.aws_scan import analyze_aws_security
1738
+ except ImportError:
1739
+ console.print(
1740
+ "[bold red]ERROR:[/bold red] the AWS cloud scanner needs boto3, which isn't "
1741
+ "installed (it's an opt-in extra to keep the base install light).\n"
1742
+ "Run: [bold]pip install narvy\\[cloud][/bold] (or `pip install boto3` "
1743
+ "into the same environment)."
1744
+ )
1745
+ raise SystemExit(2)
1746
+
1747
+ if args.format == "text":
1748
+ console.print(f"\n[bold]Narvy Cloud Scanner[/bold] (free, local, your own credentials)")
1749
+ console.print(f"Provider: {args.provider}" + (f" | Region: {args.region}" if args.region else " | All regions"))
1750
+ console.print("Using ambient credentials (aws configure / env vars / IAM role). Nothing is sent to Narvy.\n")
1751
+
1752
+ with Progress(console=console, disable=not console.is_terminal) as progress:
1753
+ task = progress.add_task("[cyan]Scanning AWS account (can take a few minutes across all regions)...", total=None)
1754
+ try:
1755
+ # Empty dict: let boto3's own ambient credential chain resolve.
1756
+ report = analyze_aws_security({}, region=args.region)
1757
+ except Exception as e:
1758
+ progress.stop()
1759
+ console.print(f"\n[bold red]ERROR: scan failed - {e}[/bold red]")
1760
+ console.print("[dim]Check that AWS credentials are configured (aws configure / AWS_ACCESS_KEY_ID env var / IAM role).[/dim]")
1761
+ raise SystemExit(2)
1762
+ progress.update(task, completed=1, description="[green]Scan complete.")
1763
+
1764
+ findings = _cloud_flatten(report)
1765
+
1766
+ if args.format == "json":
1767
+ output = json.dumps(report, indent=2, default=str)
1768
+ elif args.format == "sarif":
1769
+ output = json.dumps(_cloud_to_sarif(findings), indent=2)
1770
+ else:
1771
+ lines = [
1772
+ "=" * 60, "NARVY CLOUD POSTURE RESULTS", "=" * 60, "",
1773
+ f"Provider: {args.provider}",
1774
+ f"Total Findings: {len(findings)}", "",
1775
+ ]
1776
+ by_sev = {}
1777
+ for f in findings:
1778
+ by_sev[f["severity"]] = by_sev.get(f["severity"], 0) + 1
1779
+ for sev in ["critical", "high", "medium", "low", "info"]:
1780
+ if by_sev.get(sev):
1781
+ lines.append(f" {sev.upper()}: {by_sev[sev]}")
1782
+ lines.append("")
1783
+ for f in findings:
1784
+ lines.append(f"[{f['severity'].upper()}] ({f['category']}) {f['title']}")
1785
+ if not findings:
1786
+ lines.append("No issues found.")
1787
+ output = "\n".join(lines)
1788
+
1789
+ _write_or_print(output, args.output_file)
1790
+ _exit_on_fail_on(findings, args.fail_on)
1791
+
1792
+
1793
+ _CI_TEMPLATES = {
1794
+ "github": ("github.yml", os.path.join(".github", "workflows", "narvy.yml")),
1795
+ "gitlab": ("gitlab.yml", ".gitlab-ci.yml"),
1796
+ "bitbucket": ("bitbucket.yml", "bitbucket-pipelines.yml"),
1797
+ }
1798
+
1799
+
1800
+ def cmd_init_ci(args):
1801
+ platform = getattr(args, "platform", None) or "github"
1802
+ template_name, rel_dest = _CI_TEMPLATES[platform]
1803
+ src = os.path.join(os.path.dirname(__file__), "ci_templates", template_name)
1804
+ try:
1805
+ template_text = Path(src).read_text(encoding="utf-8")
1806
+ except OSError as e:
1807
+ console.print(f"[bold red]Could not read the {platform} CI template: {e}[/bold red]")
1808
+ raise SystemExit(1)
1809
+
1810
+ dest = Path(args.output_dir) / rel_dest
1811
+
1812
+ # stat() can raise on an unreadable parent; exit clean, not with a traceback.
1813
+ try:
1814
+ _dest_exists = dest.exists()
1815
+ except OSError as e:
1816
+ console.print(f"[bold red]Cannot access {dest}: {e}[/bold red]")
1817
+ raise SystemExit(1)
1818
+
1819
+ if _dest_exists:
1820
+ if args.force:
1821
+ pass
1822
+ elif sys.stdin.isatty() and sys.stdout.isatty():
1823
+ console.print(f"[bold yellow]{dest} already exists.[/bold yellow]")
1824
+ if platform in ("gitlab", "bitbucket"):
1825
+ console.print(
1826
+ "[dim]This is usually your existing pipeline. Overwriting REPLACES it - "
1827
+ "if you meant to add Narvy to an existing pipeline, decline and copy "
1828
+ "the job/step from the template instead.[/dim]"
1829
+ )
1830
+ try:
1831
+ proceed = Confirm.ask("Overwrite it?", default=False)
1832
+ except (EOFError, KeyboardInterrupt):
1833
+ proceed = False
1834
+ if not proceed:
1835
+ console.print("[dim]Left the existing file untouched.[/dim]")
1836
+ raise SystemExit(1)
1837
+ else:
1838
+ console.print(
1839
+ f"[bold red]{dest} already exists.[/bold red] Refusing to overwrite it in a "
1840
+ "non-interactive shell. Re-run with [bold]--force[/bold] to replace it."
1841
+ )
1842
+ raise SystemExit(1)
1843
+
1844
+ try:
1845
+ dest.parent.mkdir(parents=True, exist_ok=True)
1846
+ dest.write_text(template_text, encoding="utf-8")
1847
+ except OSError as e:
1848
+ console.print(f"[bold red]Could not write {dest}: {e}[/bold red]")
1849
+ console.print("[dim]Check the parent directory is writable, or pass a different --output-dir.[/dim]")
1850
+ raise SystemExit(1)
1851
+
1852
+ console.print(f"[bold green]Wrote {platform} CI template to {dest}[/bold green]")
1853
+ console.print(
1854
+ "[dim]It installs the CLI from PyPI with [bold]pip install narvy[/bold] "
1855
+ "and runs on every push and pull request.[/dim]"
1856
+ )
1857
+ console.print(
1858
+ "[dim]The template runs `narvy scan . --output sarif --file narvy-results.sarif "
1859
+ "--fail-on high`. Adjust --fail-on (critical/high/medium/low/any) to set how strict the "
1860
+ "build gate is, and commit the file to enable it.[/dim]"
1861
+ )
1862
+
1863
+
1864
+ def cli():
1865
+ parser = argparse.ArgumentParser(description=(
1866
+ "Narvy CLI - Static Analysis for Android APKs/AABs, iOS IPAs, "
1867
+ "Android/iOS source projects, and web/backend source projects "
1868
+ f"({web_source_analyzer.SUPPORTED_LANGUAGES_LABEL})"
1869
+ ))
1870
+ parser.add_argument(
1871
+ "--version", action="version",
1872
+ version=f"narvy {__version__}",
1873
+ help="Show the Narvy CLI version and exit.",
1874
+ )
1875
+ sub = parser.add_subparsers(dest="command", required=True)
1876
+
1877
+ p_login = sub.add_parser("login", help="Link this machine to your Narvy account")
1878
+ p_login.add_argument("--token", help="API token (skips interactive prompt)")
1879
+ p_login.set_defaults(func=cmd_login)
1880
+
1881
+ p_logout = sub.add_parser("logout", help="Remove the locally stored API token")
1882
+ p_logout.set_defaults(func=cmd_logout)
1883
+
1884
+ p_scans = sub.add_parser("scans", help="List recent scans on your Narvy account (requires `narvy login`)")
1885
+ p_scans.add_argument("--limit", type=_positive_int, default=20, help="Max number of scans to show (default: 20, must be >= 1)")
1886
+ p_scans.add_argument("--status", choices=["queued", "running", "done", "failed", "pending_sast", "scheduled"],
1887
+ help="Filter by scan status")
1888
+ p_scans.add_argument("--format", choices=["text", "json"], default="text", help="Output format (default: text)")
1889
+ p_scans.set_defaults(func=cmd_scans)
1890
+
1891
+ p_doctor = sub.add_parser("doctor", help="Check your Java/jadx/network/iOS-tooling setup before scanning")
1892
+ p_doctor.set_defaults(func=lambda args: run_doctor())
1893
+
1894
+ p_scan = sub.add_parser("scan", help="Scan an APK/AAB, an IPA, an Android/iOS source directory, or a web/backend source directory (local rules; add --upload for a full server-side scan, mobile-only)")
1895
+ p_scan.add_argument("apk_path", help=(f"Path to scan: an .apk or .aab (Android binary), an .apkm / .xapk / .apks split-APK bundle (APKMirror / APKPure / bundletool - the app module is scanned, plus the native libraries from its architecture split), an .ipa (iOS binary - unencrypted only, see docs), a directory containing an Android Gradle project (Android source), a directory containing an Xcode/Swift project (iOS source), or a directory containing a web/backend project "
1896
+ f"({web_source_analyzer.SUPPORTED_LANGUAGES_LABEL}) - web source."))
1897
+ p_scan.add_argument("--max-mem", default="4g", help="Maximum memory for JADX (e.g., '2g', '4096m'). Android-only, ignored for iOS scans. Defaults to 4g.")
1898
+ p_scan.add_argument("--force", action="store_true",
1899
+ help="Run the Android decompile even if the pre-flight memory estimate says it will likely run out of memory (Android binary scans only).")
1900
+ p_scan.add_argument("--output", "-o", default="console", choices=["console", "sarif", "json"],
1901
+ type=str.lower, help="Output format: console (default), sarif or json. sarif and json are written to a file (see --file) so a CI step can consume them - the SARIF suits GitHub/GitLab code scanning, the JSON suits a plain jq step.")
1902
+ p_scan.add_argument("--file", "-f", default=None, help="Output file for --output sarif/json (default: narvy-results.sarif or narvy-results.json). Ignored for console output.")
1903
+ p_scan.add_argument("--upload", action="store_true", help="Submit this scan's RESULTS to your Narvy account for server-side curation (ML false-positive filter, dedup, dashboard history) - the dashboard then shows exactly what this scan found, curated. Works for binaries AND source directories. Requires `narvy login` first.")
1904
+ p_scan.add_argument("--upload-binary", action="store_true", help="Legacy: instead of uploading results, upload the BINARY (.apk/.aab/.ipa) and have the server RE-SCAN it with its own engine. Implies --upload. Not supported for source directories.")
1905
+ p_scan.add_argument("--confirm-upload", action="store_true",
1906
+ help="Skip the interactive confirmation when server-side malware triage flags the file as suspicious (severity=warn, e.g. a known intentionally-vulnerable training APK), for CI/non-interactive use. With a real terminal attached, you'll be asked instead. Only ever downgrades a warn, never a hard refuse.")
1907
+ p_scan.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"],
1908
+ help="Exit 1 if any finding is at or above this severity, so a CI pipeline can block the build. Runs after --upload, so the server-side scan is still submitted.")
1909
+ p_scan.set_defaults(func=cmd_scan)
1910
+
1911
+ p_initci = sub.add_parser(
1912
+ "init-ci",
1913
+ help="Write a ready-to-use CI pipeline template (GitHub Actions / GitLab CI / Bitbucket Pipelines) that runs Narvy as a build step",
1914
+ )
1915
+ _ci_group = p_initci.add_mutually_exclusive_group()
1916
+ _ci_group.add_argument("--github", dest="platform", action="store_const", const="github",
1917
+ help="GitHub Actions -> .github/workflows/narvy.yml (default)")
1918
+ _ci_group.add_argument("--gitlab", dest="platform", action="store_const", const="gitlab",
1919
+ help="GitLab CI -> .gitlab-ci.yml")
1920
+ _ci_group.add_argument("--bitbucket", dest="platform", action="store_const", const="bitbucket",
1921
+ help="Bitbucket Pipelines -> bitbucket-pipelines.yml")
1922
+ p_initci.add_argument("--output-dir", default=".",
1923
+ help="Directory to write the template into (default: current directory)")
1924
+ p_initci.add_argument("--force", action="store_true",
1925
+ help="Overwrite an existing file without asking (for non-interactive/CI use)")
1926
+ p_initci.set_defaults(func=cmd_init_ci, platform=None)
1927
+
1928
+ p_upload_all = sub.add_parser("upload-all", help="Scan and upload every .apk/.aab/.apkm/.xapk/.ipa found in a directory to your Narvy account in one command (requires `narvy login` first)")
1929
+ p_upload_all.add_argument("directory", help="Directory to search for scan targets")
1930
+ p_upload_all.add_argument("--no-recursive", action="store_true",
1931
+ help="Only look in the top-level directory, don't walk subdirectories")
1932
+ p_upload_all.add_argument("--max-mem", default="4g", help="Maximum memory for JADX (e.g., '2g', '4096m'). Applied to each Android target found.")
1933
+ p_upload_all.add_argument("--force", action="store_true",
1934
+ help="Run each Android decompile even if the pre-flight memory estimate says it will likely run out of memory.")
1935
+ p_upload_all.add_argument("--confirm-upload", action="store_true",
1936
+ help="Skip the interactive confirmation when server-side malware triage flags a file as suspicious (severity=warn), for CI/non-interactive use. With a real terminal attached, you'll be asked per file instead. Only ever downgrades a warn, never a hard refuse.")
1937
+ p_upload_all.set_defaults(func=cmd_upload_all)
1938
+
1939
+ p_web = sub.add_parser("web-scan", help="Web vulnerability scan of a URL (Nuclei-based, no account needed). Passive by default; --active runs the full dynamic template set.")
1940
+ p_web.add_argument("url", help="Target URL to scan")
1941
+ p_web.add_argument("--active", action="store_true",
1942
+ help="Dynamic mode: run nuclei's full template set (sends non-GET probes, checks input handling, etc.) instead of the passive GET-only subset. Only scan targets you own or are explicitly authorized to test. Destructive tags (dos, fuzzing, brute-force, intrusive) stay excluded.")
1943
+ p_web.add_argument("--rate-limit", type=int, default=WEB_MAX_RATE_LIMIT,
1944
+ help=f"Requests per second (max {WEB_MAX_RATE_LIMIT}, enforced)")
1945
+ p_web.add_argument("--format", "-f", choices=["text", "json", "sarif"], default="text")
1946
+ p_web.add_argument("--output-file", "-o", help="Output file path (default: stdout)")
1947
+ p_web.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"])
1948
+ p_web.set_defaults(func=cmd_web_scan)
1949
+
1950
+ p_host = sub.add_parser("host-audit", help="Agentless Lynis host/infra audit over your own SSH access (no credentials sent to Narvy)")
1951
+ p_host.add_argument("host", help="Target hostname or IP")
1952
+ p_host.add_argument("--user", "-u", required=True, help="SSH username")
1953
+ p_host.add_argument("--port", "-p", type=int, default=22, help="SSH port (default: 22)")
1954
+ p_host.add_argument("--ask-password", action="store_true",
1955
+ help="Authenticate with an SSH password (prompted, or read from the NARVY_SSH_PASSWORD env var) instead of a key. The host key is captured on first contact (trust-on-first-use) and pinned for the duration of this run. Note: this is not a defence against an active man-in-the-middle, which would have its own key pinned; only send a password over a network you trust, and prefer key auth where possible.")
1956
+ p_host.add_argument("--privilege", choices=["auto", "root", "nopasswd_sudo", "none"],
1957
+ default="auto", help="Privilege mode (default: auto-detect)")
1958
+ p_host.add_argument("--format", "-f", choices=["text", "json", "sarif"], default="text")
1959
+ p_host.add_argument("--output-file", "-o", help="Output file path (default: stdout)")
1960
+ p_host.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"])
1961
+ p_host.set_defaults(func=cmd_host_audit)
1962
+
1963
+ p_cloud = sub.add_parser("cloud-scan", help="Cloud posture scan using your own ambient credentials (aws configure / env vars / IAM role - zero credential custody)")
1964
+ p_cloud.add_argument("--provider", choices=["aws"], default="aws", help="Cloud provider (GCP/Azure not yet in the CLI)")
1965
+ p_cloud.add_argument("--region", help="Specific region to scan (default: all regions)")
1966
+ p_cloud.add_argument("--format", "-f", choices=["text", "json", "sarif"], default="text")
1967
+ p_cloud.add_argument("--output-file", "-o", help="Output file path (default: stdout)")
1968
+ p_cloud.add_argument("--fail-on", choices=["critical", "high", "medium", "low", "any"])
1969
+ p_cloud.set_defaults(func=cmd_cloud_scan)
1970
+
1971
+ args = parser.parse_args()
1972
+ # Last-resort guard: turn any escaped exception into a clean one-line message + exit 1, never a raw traceback.
1973
+ try:
1974
+ args.func(args)
1975
+ except KeyboardInterrupt:
1976
+ console.print("[yellow]Aborted.[/yellow]")
1977
+ raise SystemExit(130)
1978
+ except SystemExit:
1979
+ raise
1980
+ except Exception as e:
1981
+ console.print(f"[bold red]Unexpected error:[/bold red] {e}")
1982
+ console.print("[dim]This looks like a bug. Please report it.[/dim]")
1983
+ raise SystemExit(1)