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