narvy-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,400 @@
1
+ """Ship a checksum-pinned Lynis to a target host for one audit, run it from a
2
+ temporary directory, then remove it. Nothing is installed on the host.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ import hashlib
8
+ import logging
9
+ import os
10
+ import re
11
+ import stat
12
+ import subprocess
13
+ import tempfile
14
+ import urllib.error
15
+ import urllib.request
16
+ import uuid
17
+ from typing import Optional
18
+
19
+ from .ssh_exec import (
20
+ HostConn,
21
+ SSHExecError,
22
+ _have,
23
+ _scrub,
24
+ _validate_conn,
25
+ known_hosts_entry_host,
26
+ ssh_exec,
27
+ )
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # Bumping the version means re-verifying the upstream tarball signature before
32
+ # recording the new hash.
33
+ LYNIS_VERSION = "3.1.7"
34
+ LYNIS_SHA256 = "b5314a07fd85fa3ffc7da57b508f0108ec3280d84e4af823f805d95cbbc2428c"
35
+ LYNIS_URL = f"https://downloads.cisofy.com/lynis/lynis-{LYNIS_VERSION}.tar.gz"
36
+
37
+ # The tmp fallback keeps unprivileged workers working when /opt is not writable.
38
+ CACHE_DIRS = (
39
+ "/opt/narvy/vendor",
40
+ os.path.join(tempfile.gettempdir(), "narvy_vendor"),
41
+ )
42
+ CACHE_FILENAME = f"lynis-{LYNIS_VERSION}.tar.gz"
43
+
44
+ DOWNLOAD_TIMEOUT = 60
45
+ _REMOTE_PREFIX = "/tmp/narvy_lynis_"
46
+ # The only shape of path this module will ever `rm -rf` on a remote host.
47
+ _WORKDIR_RE = re.compile(r"^/tmp/narvy_lynis_[0-9a-f]{16}$")
48
+
49
+ # Non-interactive SSH sessions often omit sbin, where lynis is usually installed.
50
+ _PROBE_PATH = "$PATH:/usr/local/sbin:/usr/sbin:/sbin"
51
+
52
+
53
+ class LynisBootstrapError(RuntimeError):
54
+ """Lynis could not be made available on the target. Message is always scrubbed."""
55
+
56
+
57
+ def _sha256_file(path: str) -> str:
58
+ h = hashlib.sha256()
59
+ with open(path, "rb") as fh:
60
+ for chunk in iter(lambda: fh.read(1024 * 1024), b""):
61
+ h.update(chunk)
62
+ return h.hexdigest()
63
+
64
+
65
+ def _cached_tarball() -> Optional[str]:
66
+ for directory in CACHE_DIRS:
67
+ path = os.path.join(directory, CACHE_FILENAME)
68
+ if not os.path.isfile(path):
69
+ continue
70
+ try:
71
+ digest = _sha256_file(path)
72
+ except OSError:
73
+ continue
74
+ if digest == LYNIS_SHA256:
75
+ return path
76
+ logger.warning(
77
+ "lynis_bootstrap: cached tarball %s failed the pinned checksum; "
78
+ "discarding and re-downloading", path)
79
+ try:
80
+ os.remove(path)
81
+ except OSError:
82
+ pass
83
+ return None
84
+
85
+
86
+ def _download_tarball(timeout: int = DOWNLOAD_TIMEOUT) -> str:
87
+ last_err = ""
88
+ for directory in CACHE_DIRS:
89
+ try:
90
+ os.makedirs(directory, exist_ok=True)
91
+ except OSError as exc:
92
+ last_err = f"cache dir {directory} not creatable: {exc.strerror}"
93
+ continue
94
+ if not os.access(directory, os.W_OK):
95
+ last_err = f"cache dir {directory} not writable"
96
+ continue
97
+
98
+ final = os.path.join(directory, CACHE_FILENAME)
99
+ tmp_path: Optional[str] = None
100
+ try:
101
+ fd, tmp_path = tempfile.mkstemp(prefix=".lynis_dl_", dir=directory)
102
+ os.close(fd)
103
+ with urllib.request.urlopen(LYNIS_URL, timeout=timeout) as resp, \
104
+ open(tmp_path, "wb") as out:
105
+ while True:
106
+ chunk = resp.read(1024 * 256)
107
+ if not chunk:
108
+ break
109
+ out.write(chunk)
110
+
111
+ digest = _sha256_file(tmp_path)
112
+ if digest != LYNIS_SHA256:
113
+ raise LynisBootstrapError(
114
+ f"lynis tarball checksum MISMATCH (supply-chain guard): "
115
+ f"expected {LYNIS_SHA256}, got {digest} from {LYNIS_URL}. "
116
+ f"Refusing to ship unverified code to a customer host.")
117
+
118
+ os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR |
119
+ stat.S_IRGRP | stat.S_IROTH)
120
+ # Checksum is verified before the file is published into the cache.
121
+ os.replace(tmp_path, final)
122
+ tmp_path = None
123
+ logger.info("lynis_bootstrap: cached verified lynis %s at %s",
124
+ LYNIS_VERSION, final)
125
+ return final
126
+ except LynisBootstrapError:
127
+ raise
128
+ except (urllib.error.URLError, OSError, ValueError) as exc:
129
+ last_err = f"{type(exc).__name__}: {exc}"
130
+ continue
131
+ finally:
132
+ if tmp_path and os.path.exists(tmp_path):
133
+ try:
134
+ os.remove(tmp_path)
135
+ except OSError:
136
+ pass
137
+
138
+ raise LynisBootstrapError(
139
+ f"could not obtain lynis {LYNIS_VERSION} from {LYNIS_URL} "
140
+ f"(worker needs outbound HTTPS to downloads.cisofy.com on first use): "
141
+ f"{last_err}")
142
+
143
+
144
+ def local_tarball(timeout: int = DOWNLOAD_TIMEOUT) -> str:
145
+ cached = _cached_tarball()
146
+ if cached:
147
+ return cached
148
+ return _download_tarball(timeout=timeout)
149
+
150
+
151
+ def _scp_opts(conn: HostConn, key_path: Optional[str],
152
+ known_hosts_path: str) -> list[str]:
153
+ policy = "yes" if conn.known_host_key else conn.hostkey_policy
154
+
155
+ # Mirrors ssh_exec._base_ssh_opts: ambient identity uses no key/password.
156
+ if conn.use_ambient_identity:
157
+ return [
158
+ "-o", f"StrictHostKeyChecking={policy}",
159
+ "-o", f"UserKnownHostsFile={known_hosts_path}",
160
+ "-o", "ConnectTimeout=15",
161
+ "-o", f"User={conn.user}",
162
+ "-P", str(int(conn.port)), # scp spells it -P, ssh spells it -p
163
+ ]
164
+
165
+ opts = [
166
+ "-o", f"StrictHostKeyChecking={policy}",
167
+ "-o", f"UserKnownHostsFile={known_hosts_path}",
168
+ "-o", "ConnectTimeout=15",
169
+ "-o", "NumberOfPasswordPrompts=1",
170
+ # Never offer this machine's own identities/agent to the target host.
171
+ "-o", "IdentitiesOnly=yes",
172
+ "-o", "IdentityAgent=none",
173
+ # User via -o, not user@host, so no ssh option can be smuggled through.
174
+ "-o", f"User={conn.user}",
175
+ "-P", str(int(conn.port)), # scp spells it -P, ssh spells it -p
176
+ ]
177
+ if key_path:
178
+ opts += ["-i", key_path, "-o", "BatchMode=yes"]
179
+ else:
180
+ opts += [
181
+ "-o", "BatchMode=no",
182
+ "-o", "PubkeyAuthentication=no",
183
+ "-o", "PreferredAuthentications=password",
184
+ ]
185
+ return opts
186
+
187
+
188
+ def scp_to_remote(conn: HostConn, local_path: str, remote_path: str,
189
+ timeout: int = 120) -> None:
190
+ try:
191
+ _validate_conn(conn)
192
+ except SSHExecError as exc:
193
+ raise LynisBootstrapError(f"invalid connection: {exc}") from None
194
+
195
+ if not os.path.isfile(local_path):
196
+ raise LynisBootstrapError("local file to transfer does not exist")
197
+
198
+ key_path: Optional[str] = None
199
+ known_hosts_path: Optional[str] = None
200
+ try:
201
+ kh_fd, known_hosts_path = tempfile.mkstemp(prefix="lynis_boot_kh_")
202
+ with os.fdopen(kh_fd, "w") as kh:
203
+ if conn.known_host_key:
204
+ # Non-default ports live under [host]:port in known_hosts.
205
+ entry_host = known_hosts_entry_host(conn.hostname, conn.port)
206
+ kh.write(f"{entry_host} {conn.known_host_key}\n")
207
+
208
+ if conn.private_key:
209
+ fd, key_path = tempfile.mkstemp(prefix="lynis_boot_key_",
210
+ suffix=".pem")
211
+ with os.fdopen(fd, "w") as fh:
212
+ fh.write(conn.private_key)
213
+ if not conn.private_key.endswith("\n"):
214
+ fh.write("\n")
215
+ os.chmod(key_path, stat.S_IRUSR | stat.S_IWUSR) # 0600
216
+
217
+ # `--` keeps the destination from being reinterpreted as a flag.
218
+ argv = ["scp", *_scp_opts(conn, key_path, known_hosts_path),
219
+ "--", local_path, f"{conn.hostname}:{remote_path}"]
220
+
221
+ env = os.environ.copy()
222
+ if conn.password and not conn.private_key:
223
+ if not _have("sshpass"):
224
+ raise LynisBootstrapError(
225
+ "password auth requested but sshpass is not installed")
226
+ env["SSHPASS"] = conn.password # env, never argv
227
+ argv = ["sshpass", "-e", *argv]
228
+
229
+ try:
230
+ proc = subprocess.run(argv, capture_output=True, text=True,
231
+ timeout=timeout, env=env)
232
+ except subprocess.TimeoutExpired as exc:
233
+ raise LynisBootstrapError(
234
+ f"scp to {conn.hostname}:{conn.port} timed out after "
235
+ f"{timeout}s") from None
236
+ except OSError as exc:
237
+ raise LynisBootstrapError(
238
+ f"scp could not be executed: {exc.strerror}") from None
239
+
240
+ if proc.returncode != 0:
241
+ raise LynisBootstrapError(
242
+ f"scp to {conn.hostname}:{conn.port} failed "
243
+ f"(rc={proc.returncode}): {_scrub(proc.stderr)[:240]}")
244
+ finally:
245
+ for path in (key_path, known_hosts_path):
246
+ if path and os.path.exists(path):
247
+ try:
248
+ os.remove(path)
249
+ except OSError:
250
+ logger.warning(
251
+ "lynis_bootstrap: could not remove temp file")
252
+
253
+
254
+ def _new_workdir() -> str:
255
+ return f"{_REMOTE_PREFIX}{uuid.uuid4().hex[:16]}"
256
+
257
+
258
+ def workdir_for_cmd(lynis_cmd: str) -> Optional[str]:
259
+ if not lynis_cmd or not lynis_cmd.startswith(_REMOTE_PREFIX):
260
+ return None
261
+ candidate = "/".join(lynis_cmd.split("/")[:3])
262
+ return candidate if _WORKDIR_RE.match(candidate) else None
263
+
264
+
265
+ def _probe_system_lynis(conn: HostConn, privilege_prefix: str,
266
+ timeout: int) -> Optional[str]:
267
+ probes = [
268
+ ("plain", "command -v lynis 2>/dev/null || true"),
269
+ ("sbin", f'PATH="{_PROBE_PATH}" command -v lynis 2>/dev/null || true'),
270
+ ]
271
+ if privilege_prefix.strip():
272
+ # `command` is a shell builtin, so sudo needs a shell to run it.
273
+ probes.append(
274
+ ("privileged",
275
+ f"{privilege_prefix}sh -c 'PATH=\"{_PROBE_PATH}\" command -v lynis' "
276
+ f"2>/dev/null || true"))
277
+
278
+ for kind, cmd in probes:
279
+ try:
280
+ res = ssh_exec(conn, cmd, timeout=timeout)
281
+ except SSHExecError:
282
+ # A failed probe means no SSH session at all, not a Lynis-availability result.
283
+ raise
284
+ found = (res.stdout or "").strip().splitlines()
285
+ found = [ln.strip() for ln in found if ln.strip().startswith("/")]
286
+ if not found:
287
+ continue
288
+ return "lynis" if kind == "plain" else found[0]
289
+ return None
290
+
291
+
292
+ def ensure_lynis(conn: HostConn, privilege_prefix: str,
293
+ timeout: int = 120) -> tuple[str, bool]:
294
+ """Make lynis runnable on `conn`; returns (lynis_cmd, ephemeral). When ephemeral, caller MUST cleanup_lynis(). privilege_prefix must match the audit's."""
295
+ privilege_prefix = privilege_prefix or ""
296
+
297
+ existing = _probe_system_lynis(conn, privilege_prefix, timeout=min(timeout, 30))
298
+ if existing:
299
+ logger.info("lynis_bootstrap: using system lynis on target")
300
+ return (existing, False)
301
+
302
+ tarball = local_tarball()
303
+ workdir = _new_workdir()
304
+ remote_tar = f"{workdir}/lynis.tar.gz"
305
+ wrapper = f"{workdir}/narvy-lynis"
306
+ lynis_script = f"{workdir}/lynis/lynis"
307
+
308
+ # umask 077 keeps other local users from tampering with a tree we execute.
309
+ try:
310
+ res = ssh_exec(conn, f"umask 077; mkdir -p {workdir} && echo NARVY_MKDIR_OK",
311
+ timeout=timeout)
312
+ except SSHExecError as exc:
313
+ raise LynisBootstrapError(f"could not create remote workdir: {exc}") from None
314
+ if "NARVY_MKDIR_OK" not in (res.stdout or ""):
315
+ raise LynisBootstrapError(
316
+ f"could not create remote workdir under /tmp "
317
+ f"(rc={res.returncode}): {_scrub(res.stderr)[:160]}")
318
+
319
+ try:
320
+ scp_to_remote(conn, tarball, remote_tar, timeout=timeout)
321
+
322
+ # Written via `printf`, so \\n are literal backslash-n that printf turns into newlines.
323
+ wrapper_body = (
324
+ f"#!/bin/sh\\n"
325
+ f"# Narvy ephemeral lynis wrapper - removed after the audit.\\n"
326
+ f"cd {workdir}/lynis || exit 1\\n"
327
+ f'exec ./lynis --usecwd --logfile {workdir}/lynis.log "$@" < /dev/null\\n'
328
+ )
329
+ setup = (
330
+ f"umask 077; cd {workdir} && "
331
+ f"tar -xzf lynis.tar.gz && rm -f lynis.tar.gz && "
332
+ f"test -f {lynis_script} && chmod 0700 {lynis_script} && "
333
+ f"printf '{wrapper_body}' > {wrapper} && chmod 0700 {wrapper} && "
334
+ f"test -x {lynis_script} && test -x {wrapper} && echo NARVY_SETUP_OK"
335
+ )
336
+ try:
337
+ res = ssh_exec(conn, setup, timeout=timeout)
338
+ except SSHExecError as exc:
339
+ raise LynisBootstrapError(f"remote extraction failed: {exc}") from None
340
+ if "NARVY_SETUP_OK" not in (res.stdout or ""):
341
+ raise LynisBootstrapError(
342
+ f"remote extraction failed (rc={res.returncode}): "
343
+ f"{_scrub(res.stderr)[:200]}")
344
+
345
+ # Chown the tree to root before a privileged run (root executing a user-writable tree is a TOCTOU).
346
+ if privilege_prefix.strip():
347
+ try:
348
+ res = ssh_exec(conn, f"{privilege_prefix}chown -R 0:0 {workdir} "
349
+ f"&& echo NARVY_CHOWN_OK", timeout=timeout)
350
+ except SSHExecError as exc:
351
+ raise LynisBootstrapError(
352
+ f"could not root-own the lynis tree for the privileged "
353
+ f"audit: {exc}") from None
354
+ if "NARVY_CHOWN_OK" not in (res.stdout or ""):
355
+ raise LynisBootstrapError(
356
+ f"could not root-own the lynis tree for the privileged "
357
+ f"audit (rc={res.returncode}): {_scrub(res.stderr)[:160]}")
358
+
359
+ # Probe through the wrapper so the cd-into-untarred-dir requirement is exercised.
360
+ try:
361
+ res = ssh_exec(conn, f"{privilege_prefix}{wrapper} --version",
362
+ timeout=60)
363
+ except SSHExecError as exc:
364
+ raise LynisBootstrapError(
365
+ f"uploaded lynis failed to execute: {exc}") from None
366
+ version = (res.stdout or "").strip()
367
+ if res.returncode != 0 or not version:
368
+ raise LynisBootstrapError(
369
+ f"uploaded lynis did not run (rc={res.returncode}): "
370
+ f"{_scrub(res.stderr)[:200]}")
371
+
372
+ logger.info("lynis_bootstrap: uploaded ephemeral lynis %s to target",
373
+ version.splitlines()[0][:16])
374
+ return (wrapper, True)
375
+ except Exception:
376
+ cleanup_lynis(conn, workdir)
377
+ raise
378
+
379
+
380
+ def cleanup_lynis(conn: HostConn, workdir: str) -> None:
381
+ """Best-effort `rm -rf` of an uploaded tree; refuses any path outside /tmp/narvy_lynis_<hex16>. Never raises."""
382
+ if not workdir or not _WORKDIR_RE.match(workdir):
383
+ logger.error(
384
+ "lynis_bootstrap: refusing to rm -rf a non-Narvy path")
385
+ return
386
+ try:
387
+ res = ssh_exec(conn, f"rm -rf {workdir} 2>/dev/null; "
388
+ f"test -e {workdir} && echo NARVY_STILL_THERE || "
389
+ f"echo NARVY_CLEAN_OK", timeout=60)
390
+ if "NARVY_STILL_THERE" not in (res.stdout or ""):
391
+ return
392
+ # Lynis run under sudo may have left root-owned files behind.
393
+ ssh_exec(conn, f"sudo -n rm -rf {workdir} 2>/dev/null || true",
394
+ timeout=60)
395
+ except SSHExecError:
396
+ logger.warning(
397
+ "lynis_bootstrap: could not remove remote workdir; it is under "
398
+ "/tmp and will not survive a reboot")
399
+ except Exception: # noqa: BLE001
400
+ logger.warning("lynis_bootstrap: unexpected error during cleanup")