narvy-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. narvy/__init__.py +3 -0
  2. narvy/android/__init__.py +0 -0
  3. narvy/android/source_analyzer.py +268 -0
  4. narvy/android/split_bundle.py +475 -0
  5. narvy/android_rule_context.py +196 -0
  6. narvy/apk_memory_preflight.py +248 -0
  7. narvy/auth.py +40 -0
  8. narvy/ci_templates/bitbucket.yml +25 -0
  9. narvy/ci_templates/github.yml +60 -0
  10. narvy/ci_templates/gitlab.yml +32 -0
  11. narvy/cloud/__init__.py +0 -0
  12. narvy/cloud/aws_scan.py +1188 -0
  13. narvy/comment_filter.py +134 -0
  14. narvy/crypto_taint_lite.py +82 -0
  15. narvy/decompiler.py +337 -0
  16. narvy/doctor.py +244 -0
  17. narvy/host/__init__.py +0 -0
  18. narvy/host/audit.py +157 -0
  19. narvy/host/host_knowledge.py +982 -0
  20. narvy/host/lynis_bootstrap.py +400 -0
  21. narvy/host/lynis_parser.py +358 -0
  22. narvy/host/narvy_checks.py +789 -0
  23. narvy/host/report.py +69 -0
  24. narvy/host/ssh_exec.py +219 -0
  25. narvy/ios/__init__.py +0 -0
  26. narvy/ios/binary_analyzer.py +1201 -0
  27. narvy/ios/plist_checks.py +249 -0
  28. narvy/ios/source_analyzer.py +301 -0
  29. narvy/ios/third_party_filter.py +242 -0
  30. narvy/ios/trust_all_context.py +66 -0
  31. narvy/main.py +1983 -0
  32. narvy/native_hardening.py +503 -0
  33. narvy/reporter.py +151 -0
  34. narvy/rule_engine.py +57 -0
  35. narvy/rules/android/config.yml +77 -0
  36. narvy/rules/android/crypto.yml +34 -0
  37. narvy/rules/android/secrets.yml +161 -0
  38. narvy/rules/android/storage.yml +24 -0
  39. narvy/rules/android/webview.yml +23 -0
  40. narvy/rules/android.yml +219 -0
  41. narvy/rules/ios/objc/crypto.yml +56 -0
  42. narvy/rules/ios/objc/network.yml +67 -0
  43. narvy/rules/ios/objc/secrets.yml +79 -0
  44. narvy/rules/ios/objc/storage.yml +45 -0
  45. narvy/rules/ios/objc/webview.yml +45 -0
  46. narvy/rules/ios_swift.yml +327 -0
  47. narvy/rules/web/go.yml +2837 -0
  48. narvy/rules/web/java.yml +1576 -0
  49. narvy/rules/web/javascript.yml +3683 -0
  50. narvy/rules/web/kotlin.yml +413 -0
  51. narvy/rules/web/local/csharp_narvy/config.yml +61 -0
  52. narvy/rules/web/local/csharp_narvy/crypto.yml +64 -0
  53. narvy/rules/web/local/csharp_narvy/deserialization.yml +59 -0
  54. narvy/rules/web/local/csharp_narvy/injection.yml +122 -0
  55. narvy/rules/web/local/csharp_narvy/xxe.yml +48 -0
  56. narvy/rules/web/local/java_narvy/auth_jwt.yml +134 -0
  57. narvy/rules/web/local/java_narvy/deserialization.yml +108 -0
  58. narvy/rules/web/local/java_narvy/mybatis.yml +39 -0
  59. narvy/rules/web/local/java_narvy/snakeyaml.yml +34 -0
  60. narvy/rules/web/local/java_narvy/spring_authz.yml +32 -0
  61. narvy/rules/web/local/java_narvy/spring_config.yml +67 -0
  62. narvy/rules/web/local/java_narvy/spring_hardening.yml +291 -0
  63. narvy/rules/web/local/java_narvy/sqli.yml +235 -0
  64. narvy/rules/web/local/java_narvy/xxe.yml +212 -0
  65. narvy/rules/web/php.yml +1644 -0
  66. narvy/rules/web/python.yml +3967 -0
  67. narvy/rules/web/ruby.yml +703 -0
  68. narvy/rules/web/rust.yml +258 -0
  69. narvy/rules/web/secrets.yml +1420 -0
  70. narvy/rules/web/secrets_supplement.yml +383 -0
  71. narvy/sca/__init__.py +1 -0
  72. narvy/sca/android_deps.py +349 -0
  73. narvy/sca/ios_deps.py +578 -0
  74. narvy/sca/osv_client.py +617 -0
  75. narvy/sca/web_deps.py +955 -0
  76. narvy/scope_config.py +195 -0
  77. narvy/semgrep_engine.py +219 -0
  78. narvy/stack_protector_evidence.py +96 -0
  79. narvy/third_party_filter.py +211 -0
  80. narvy/uploader.py +92 -0
  81. narvy/weak_prng_context.py +270 -0
  82. narvy/web/__init__.py +0 -0
  83. narvy/web/nuclei_binary.py +105 -0
  84. narvy/web/scan_blocklist.py +96 -0
  85. narvy/web/scanner.py +842 -0
  86. narvy/web/source_analyzer.py +714 -0
  87. narvy/web/ssrf_guard.py +374 -0
  88. narvy_cli-1.0.0.dist-info/METADATA +165 -0
  89. narvy_cli-1.0.0.dist-info/RECORD +93 -0
  90. narvy_cli-1.0.0.dist-info/WHEEL +5 -0
  91. narvy_cli-1.0.0.dist-info/entry_points.txt +2 -0
  92. narvy_cli-1.0.0.dist-info/licenses/LICENSE +202 -0
  93. narvy_cli-1.0.0.dist-info/top_level.txt +1 -0
narvy/host/report.py ADDED
@@ -0,0 +1,69 @@
1
+ """Normalize host-audit findings into the CLI's own flat finding schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Dict, List
6
+
7
+ from .lynis_parser import ENGINE_NAME
8
+
9
+ _SEV_MAP = {
10
+ "critical": "Critical", "high": "High", "medium": "Medium",
11
+ "low": "Low", "info": "Info",
12
+ }
13
+
14
+ _OWASP_MISCONFIG = "A05:2021 - Security Misconfiguration"
15
+ _OWASP_OUTDATED = "A06:2021 - Vulnerable and Outdated Components"
16
+
17
+
18
+ def _owasp_for(finding) -> str:
19
+ if (finding.get("category") or "") == "Packages & Patching":
20
+ return _OWASP_OUTDATED
21
+ return _OWASP_MISCONFIG
22
+
23
+
24
+ def normalize_host_findings(findings: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
25
+ out: List[Dict[str, Any]] = []
26
+ for i, f in enumerate(findings, start=1):
27
+ sev = _SEV_MAP.get(str(f.get("severity", "")).lower(), "Low")
28
+ desc = f.get("description") or f.get("title") or ""
29
+ details = f.get("details")
30
+ cis = f.get("cis")
31
+ extra = []
32
+ if details:
33
+ extra.append(f"Details: {details}")
34
+ if cis:
35
+ extra.append(f"Control: {cis}")
36
+ full_desc = desc + (("\n\n" + "\n".join(extra)) if extra else "")
37
+
38
+ category = f.get("category") or "Host Hardening"
39
+ title = f.get("title") or f.get("test_id") or "Host finding"
40
+ test_id = f.get("test_id") or ""
41
+ out.append({
42
+ "id": i,
43
+ "title": title,
44
+ "type": category,
45
+ "rule_id": f.get("control_id") or test_id,
46
+ "code_snippet": f.get("discriminator") or f"{f.get('control_id') or test_id}: {title}",
47
+ "severity": sev,
48
+ "category": category,
49
+ "description": full_desc,
50
+ "location": f.get("location") or "",
51
+ "cwe": f.get("cwe") or "",
52
+ "cis": cis or "",
53
+ "owasp": _owasp_for(f),
54
+ "remediation": f.get("remediation") or "",
55
+ "reference": f.get("reference") or "",
56
+ "engine": f.get("engine") or ENGINE_NAME,
57
+ "confidence": f.get("confidence") or "HIGH",
58
+ "surface": "infrastructure",
59
+ })
60
+ return out
61
+
62
+
63
+ def severity_counts(vulns: List[Dict[str, Any]]) -> Dict[str, int]:
64
+ counts = {"Critical": 0, "High": 0, "Medium": 0, "Low": 0, "Info": 0}
65
+ for v in vulns:
66
+ s = v.get("severity", "Low")
67
+ if s in counts:
68
+ counts[s] += 1
69
+ return counts
narvy/host/ssh_exec.py ADDED
@@ -0,0 +1,219 @@
1
+ """Agentless SSH exec helper for host audits, built on the system ssh client.
2
+ Private keys go to a 0600 temp file wiped after use; hostname/user are validated
3
+ before argv is built; host keys use a per-call known_hosts file."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import logging
8
+ import os
9
+ import re
10
+ import stat
11
+ import subprocess
12
+ import tempfile
13
+ from dataclasses import dataclass
14
+ from typing import Optional
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ DEFAULT_TIMEOUT = 120
19
+ # accept-new trusts an unknown host on first contact but rejects a CHANGED key.
20
+ DEFAULT_HOSTKEY_POLICY = "accept-new"
21
+
22
+ # Leading '-' is rejected to prevent ssh option smuggling.
23
+ _HOSTNAME_RE = re.compile(r"^(?!-)[A-Za-z0-9._:\-]{1,255}$")
24
+ _USER_RE = re.compile(r"^(?!-)[A-Za-z0-9._\-]{1,64}$")
25
+
26
+
27
+ class SSHExecError(RuntimeError):
28
+ """Transport or parameter-validation failure; a non-zero remote exit is not one."""
29
+
30
+
31
+ @dataclass
32
+ class SSHResult:
33
+ returncode: int
34
+ stdout: str
35
+ stderr: str
36
+
37
+
38
+ @dataclass
39
+ class HostConn:
40
+ """Connection parameters for one target host; credentials are plaintext here."""
41
+
42
+ hostname: str
43
+ user: str
44
+ port: int = 22
45
+ private_key: Optional[str] = None
46
+ password: Optional[str] = None
47
+ known_host_key: Optional[str] = None
48
+ hostkey_policy: str = DEFAULT_HOSTKEY_POLICY
49
+ # True lets ssh resolve auth from ~/.ssh/config/agent instead of explicit credentials.
50
+ use_ambient_identity: bool = False
51
+
52
+
53
+ def _validate_conn(conn: HostConn) -> None:
54
+ if not conn.hostname or not _HOSTNAME_RE.match(conn.hostname):
55
+ raise SSHExecError("invalid hostname (charset / leading-dash rejected)")
56
+ if not conn.user or not _USER_RE.match(conn.user):
57
+ raise SSHExecError("invalid ssh user (charset / leading-dash rejected)")
58
+ try:
59
+ port = int(conn.port)
60
+ except (TypeError, ValueError):
61
+ raise SSHExecError("invalid ssh port")
62
+ if not (1 <= port <= 65535):
63
+ raise SSHExecError("ssh port out of range")
64
+ if conn.password and not conn.private_key and not conn.known_host_key:
65
+ # Without a pinned key a first-connect MITM captures the cleartext password.
66
+ raise SSHExecError(
67
+ "password auth requires a pinned host key (known_host_key); "
68
+ "refusing accept-new TOFU for password targets")
69
+
70
+
71
+ def known_hosts_entry_host(hostname: str, port: int) -> str:
72
+ """known_hosts keys a non-default port as `[host]:port`."""
73
+ return hostname if int(port) == 22 else f"[{hostname}]:{int(port)}"
74
+
75
+
76
+ def capture_host_key(hostname: str, port: int = 22,
77
+ timeout: int = 15) -> Optional[str]:
78
+ """ssh-keyscan the host and return its strongest host key ("algo base64")."""
79
+ if not _HOSTNAME_RE.match(hostname or ""):
80
+ return None
81
+ try:
82
+ port = int(port)
83
+ except (TypeError, ValueError):
84
+ return None
85
+ if not (1 <= port <= 65535):
86
+ return None
87
+ try:
88
+ proc = subprocess.run(
89
+ ["ssh-keyscan", "-p", str(port), "-T", str(timeout), hostname],
90
+ capture_output=True, text=True, timeout=timeout + 10,
91
+ )
92
+ except (subprocess.TimeoutExpired, OSError):
93
+ return None
94
+
95
+ best = None
96
+ for line in (proc.stdout or "").splitlines():
97
+ line = line.strip()
98
+ if not line or line.startswith("#"):
99
+ continue
100
+ parts = line.split(None, 2)
101
+ if len(parts) < 3:
102
+ continue
103
+ algo, key = parts[1], parts[2]
104
+ entry = f"{algo} {key}"
105
+ if algo == "ssh-ed25519":
106
+ return entry
107
+ if best is None:
108
+ best = entry
109
+ return best
110
+
111
+
112
+ def _base_ssh_opts(conn: HostConn, key_path: Optional[str],
113
+ known_hosts_path: str) -> list[str]:
114
+ policy = "yes" if conn.known_host_key else conn.hostkey_policy
115
+
116
+ if conn.use_ambient_identity:
117
+ return [
118
+ "-o", f"StrictHostKeyChecking={policy}",
119
+ "-o", f"UserKnownHostsFile={known_hosts_path}",
120
+ "-o", "ConnectTimeout=15",
121
+ "-p", str(int(conn.port)),
122
+ "-l", conn.user,
123
+ ]
124
+
125
+ opts = [
126
+ "-o", f"StrictHostKeyChecking={policy}",
127
+ "-o", f"UserKnownHostsFile={known_hosts_path}",
128
+ "-o", "ConnectTimeout=15",
129
+ "-o", "NumberOfPasswordPrompts=1",
130
+ # Never offer this machine's own identities or agent to the target.
131
+ "-o", "IdentitiesOnly=yes",
132
+ "-o", "IdentityAgent=none",
133
+ "-p", str(int(conn.port)),
134
+ "-l", conn.user,
135
+ ]
136
+ if key_path:
137
+ opts += ["-i", key_path, "-o", "BatchMode=yes"]
138
+ else:
139
+ opts += [
140
+ "-o", "BatchMode=no",
141
+ "-o", "PubkeyAuthentication=no",
142
+ "-o", "PreferredAuthentications=password",
143
+ ]
144
+ return opts
145
+
146
+
147
+ def ssh_exec(conn: HostConn, remote_cmd: str,
148
+ timeout: int = DEFAULT_TIMEOUT) -> SSHResult:
149
+ """Run one command on `conn` over SSH; `remote_cmd` reaches the shell as one arg. Non-zero exit is returned, not raised."""
150
+ _validate_conn(conn)
151
+
152
+ key_path: Optional[str] = None
153
+ known_hosts_path: Optional[str] = None
154
+ try:
155
+ kh_fd, known_hosts_path = tempfile.mkstemp(prefix="host_audit_kh_")
156
+ with os.fdopen(kh_fd, "w") as kh:
157
+ if conn.known_host_key:
158
+ kh.write(f"{known_hosts_entry_host(conn.hostname, conn.port)} "
159
+ f"{conn.known_host_key}\n")
160
+
161
+ if conn.private_key:
162
+ fd, key_path = tempfile.mkstemp(prefix="host_audit_key_", suffix=".pem")
163
+ with os.fdopen(fd, "w") as fh:
164
+ fh.write(conn.private_key)
165
+ if not conn.private_key.endswith("\n"):
166
+ fh.write("\n")
167
+ os.chmod(key_path, stat.S_IRUSR | stat.S_IWUSR)
168
+
169
+ argv = ["ssh", *_base_ssh_opts(conn, key_path, known_hosts_path),
170
+ conn.hostname, remote_cmd]
171
+
172
+ env = os.environ.copy()
173
+ if conn.password and not conn.private_key:
174
+ if _have("sshpass"):
175
+ env["SSHPASS"] = conn.password
176
+ argv = ["sshpass", "-e", *argv]
177
+ else:
178
+ raise SSHExecError(
179
+ "password auth requested but sshpass is not installed")
180
+
181
+ try:
182
+ proc = subprocess.run(
183
+ argv, capture_output=True, text=True, timeout=timeout, env=env,
184
+ )
185
+ except subprocess.TimeoutExpired as exc:
186
+ raise SSHExecError(
187
+ f"ssh to {conn.hostname}:{conn.port} timed out "
188
+ f"after {timeout}s") from exc
189
+
190
+ # ssh reserves exit 255 for its own failures; remote codes are < 255.
191
+ if proc.returncode == 255:
192
+ raise SSHExecError(
193
+ f"ssh transport failure to {conn.hostname}:{conn.port}: "
194
+ f"{_scrub(proc.stderr)[:240]}")
195
+
196
+ return SSHResult(proc.returncode, proc.stdout, proc.stderr)
197
+ finally:
198
+ for p in (key_path, known_hosts_path):
199
+ if p and os.path.exists(p):
200
+ try:
201
+ os.remove(p)
202
+ except OSError:
203
+ logger.warning("ssh_exec: could not remove temp file")
204
+
205
+
206
+ def ssh_probe(conn: HostConn, timeout: int = 20) -> SSHResult:
207
+ """Cheap connectivity and identity check."""
208
+ return ssh_exec(conn, "echo NARVY_SSH_OK; id -un; uname -a", timeout=timeout)
209
+
210
+
211
+ def _have(binary: str) -> bool:
212
+ from shutil import which
213
+ return which(binary) is not None
214
+
215
+
216
+ def _scrub(text: str) -> str:
217
+ if not text:
218
+ return ""
219
+ return text.replace("\r", " ").strip()
narvy/ios/__init__.py ADDED
File without changes