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/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
|