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/doctor.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""`narvy doctor`: preflight environment check.
|
|
2
|
+
Prints a PASS/FAIL list with a concrete fix for each failure."""
|
|
3
|
+
import os
|
|
4
|
+
import platform
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
from rich.markup import escape as _rich_escape
|
|
13
|
+
|
|
14
|
+
from . import decompiler
|
|
15
|
+
from .ios import binary_analyzer as ios_binary_analyzer
|
|
16
|
+
|
|
17
|
+
console = Console()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _java_version(java_bin):
|
|
21
|
+
"""Run `java -version` and return (version_line, major_version_int_or_None)."""
|
|
22
|
+
result = subprocess.run([java_bin, "-version"], capture_output=True, text=True, timeout=10)
|
|
23
|
+
out = result.stderr or result.stdout or ""
|
|
24
|
+
version_line = out.splitlines()[0] if out else "unknown version"
|
|
25
|
+
# Handles both the old "1.8.0_501" scheme and the modern "17.0.2" one.
|
|
26
|
+
m = re.search(r'version "(\d+)(?:\.(\d+))?', out)
|
|
27
|
+
if not m:
|
|
28
|
+
return version_line, None
|
|
29
|
+
major = int(m.group(1))
|
|
30
|
+
if major == 1 and m.group(2):
|
|
31
|
+
major = int(m.group(2))
|
|
32
|
+
return version_line, major
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _check_java():
|
|
36
|
+
try:
|
|
37
|
+
jre_bin_dir = decompiler.resolve_java_home()
|
|
38
|
+
except Exception as e:
|
|
39
|
+
return False, f"No usable Java found and auto-download failed: {e}", (
|
|
40
|
+
"Check your internet connection can reach api.adoptium.net and "
|
|
41
|
+
"github.com. If it's not a network issue, install a JRE 11+ "
|
|
42
|
+
"yourself: https://adoptium.net"
|
|
43
|
+
)
|
|
44
|
+
if jre_bin_dir:
|
|
45
|
+
return True, f"Using our own auto-downloaded JRE 17 ({jre_bin_dir}) - your system Java was missing or too old, no action needed", None
|
|
46
|
+
java = shutil.which("java")
|
|
47
|
+
version_line, _ = _java_version(java) if java else ("system java", None)
|
|
48
|
+
return True, version_line, None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _check_jadx():
|
|
52
|
+
try:
|
|
53
|
+
jadx_bin = decompiler.resolve_jadx_binary()
|
|
54
|
+
except Exception as e:
|
|
55
|
+
return False, "Not found, auto-download failed", (
|
|
56
|
+
f"Download failed: {e}. Check your internet connection can reach "
|
|
57
|
+
"github.com, or manually install jadx and put it on PATH: "
|
|
58
|
+
"https://github.com/skylot/jadx/releases"
|
|
59
|
+
)
|
|
60
|
+
return True, jadx_bin, None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_write_access():
|
|
64
|
+
import os
|
|
65
|
+
test_dir = os.path.expanduser("~/.narvy")
|
|
66
|
+
try:
|
|
67
|
+
os.makedirs(test_dir, exist_ok=True)
|
|
68
|
+
test_file = os.path.join(test_dir, ".doctor_write_test")
|
|
69
|
+
with open(test_file, "w") as f:
|
|
70
|
+
f.write("ok")
|
|
71
|
+
os.remove(test_file)
|
|
72
|
+
return True, test_dir, None
|
|
73
|
+
except Exception as e:
|
|
74
|
+
return False, f"Cannot write to {test_dir}: {e}", (
|
|
75
|
+
"Check folder permissions, or that no antivirus/sync tool (OneDrive, "
|
|
76
|
+
"Dropbox) is locking files in your home directory."
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _check_icdump():
|
|
81
|
+
"""Informational only: iOS binary scans degrade to a LIEF-only Obj-C pass without it."""
|
|
82
|
+
if ios_binary_analyzer.icdump_available():
|
|
83
|
+
return True, "Installed - full Obj-C class/method/property introspection available for iOS binary (.ipa) scans", None
|
|
84
|
+
import platform as _platform
|
|
85
|
+
if _platform.system() == "Windows":
|
|
86
|
+
detail = "Not installed (expected on Windows - no icdump wheel exists for this platform)"
|
|
87
|
+
else:
|
|
88
|
+
detail = "Not installed - iOS binary scans will fall back to symbol-table-only Obj-C detection"
|
|
89
|
+
return True, detail, (
|
|
90
|
+
"Optional: `pip install narvy[ios-full]` (Linux/macOS/WSL only - no Windows wheel exists) "
|
|
91
|
+
"for full Obj-C class/method/property introspection on .ipa scans. Not required - iOS binary "
|
|
92
|
+
"scanning still works without it, just with coarser Obj-C detection (class names only, via LIEF's "
|
|
93
|
+
"symbol table, no methods/properties/protocols)."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _check_memory():
|
|
98
|
+
"""Report how much RAM is free and what size of app that buys."""
|
|
99
|
+
from .apk_memory_preflight import (
|
|
100
|
+
HEAP_MB_PER_CLASS, JVM_RSS_OVERHEAD, RAM_SAFETY_RESERVE_MB, available_ram_mb,
|
|
101
|
+
)
|
|
102
|
+
avail = available_ram_mb()
|
|
103
|
+
if avail is None:
|
|
104
|
+
return True, "Could not determine available memory on this platform", None
|
|
105
|
+
usable_heap_mb = max(0, (avail - RAM_SAFETY_RESERVE_MB) / JVM_RSS_OVERHEAD)
|
|
106
|
+
classes = int(usable_heap_mb / HEAP_MB_PER_CLASS)
|
|
107
|
+
detail = (
|
|
108
|
+
f"{avail / 1024:.1f} GB available - enough Java heap for an app of roughly "
|
|
109
|
+
f"{classes // 1000}k classes (default --max-mem 4g handles ~80k)"
|
|
110
|
+
)
|
|
111
|
+
if avail < 5 * 1024:
|
|
112
|
+
return True, detail, (
|
|
113
|
+
"Large commercial apps (large production app scale) need 8-24 GB of free "
|
|
114
|
+
"memory to decompile. `narvy scan` will tell you before it starts, and "
|
|
115
|
+
"won't waste your time failing halfway - but on this machine expect to be "
|
|
116
|
+
"limited to smaller apps, or use `--upload` for the hosted scan."
|
|
117
|
+
)
|
|
118
|
+
return True, detail, None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _check_network():
|
|
122
|
+
import socket
|
|
123
|
+
for host in ("github.com", "api.adoptium.net"):
|
|
124
|
+
try:
|
|
125
|
+
socket.create_connection((host, 443), timeout=5)
|
|
126
|
+
except Exception as e:
|
|
127
|
+
return False, f"Cannot reach {host}: {e}", (
|
|
128
|
+
"Check your internet connection / firewall / proxy settings. "
|
|
129
|
+
f"{host} is needed for jadx/JRE auto-download."
|
|
130
|
+
)
|
|
131
|
+
return True, "github.com + api.adoptium.net reachable (jadx + JRE auto-download)", None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _check_semgrep():
|
|
135
|
+
"""Informational: semgrep drives the deep AST/taint pass. A broken install leaves it off
|
|
136
|
+
PATH and `scan` silently falls back to regex-only rules."""
|
|
137
|
+
from . import semgrep_engine
|
|
138
|
+
if semgrep_engine.is_available():
|
|
139
|
+
return True, f"{shutil.which('semgrep')}", None
|
|
140
|
+
return True, "Not on PATH - scans fall back to regex-only (reduced coverage)", (
|
|
141
|
+
"semgrep is the deep structural analysis engine for `narvy scan` "
|
|
142
|
+
"(Android/iOS/web source and binary modes). Without it, scans run regex "
|
|
143
|
+
"rules only and miss AST/taint-based findings. Install it: "
|
|
144
|
+
"pip install semgrep==1.152.0 (it ships as a dependency of narvy, "
|
|
145
|
+
"so a missing binary usually means a broken venv - reinstall the CLI)."
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _check_nuclei():
|
|
150
|
+
"""Informational: needed by `narvy web-scan` only."""
|
|
151
|
+
from .web import nuclei_binary
|
|
152
|
+
try:
|
|
153
|
+
nuclei_bin = nuclei_binary.resolve_nuclei_binary()
|
|
154
|
+
version = nuclei_binary.verify_nuclei(nuclei_bin)
|
|
155
|
+
except Exception as e:
|
|
156
|
+
return True, f"Not available yet - auto-download failed: {e}", (
|
|
157
|
+
"Needed only for `narvy web-scan`. Check your internet "
|
|
158
|
+
"connection can reach github.com, or install nuclei yourself "
|
|
159
|
+
"and put it on PATH: https://github.com/projectdiscovery/nuclei/releases"
|
|
160
|
+
)
|
|
161
|
+
return True, f"{nuclei_bin} ({version.splitlines()[0][:60]})", None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _check_ssh_client():
|
|
165
|
+
"""Informational: `narvy host-audit` uses the system ssh/scp binaries."""
|
|
166
|
+
ssh_bin = shutil.which("ssh")
|
|
167
|
+
scp_bin = shutil.which("scp")
|
|
168
|
+
if not ssh_bin or not scp_bin:
|
|
169
|
+
missing = "ssh" if not ssh_bin else "scp"
|
|
170
|
+
return True, f"{missing} not found on PATH", (
|
|
171
|
+
"Needed only for `narvy host-audit`. Install an OpenSSH "
|
|
172
|
+
"client (usually pre-installed on Linux/macOS; on Windows, "
|
|
173
|
+
"enable the built-in OpenSSH client or use WSL)."
|
|
174
|
+
)
|
|
175
|
+
return True, f"{ssh_bin}", None
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _check_boto3():
|
|
179
|
+
"""Informational: boto3 is an opt-in extra needed by `narvy cloud-scan`."""
|
|
180
|
+
try:
|
|
181
|
+
import boto3 # noqa: F401
|
|
182
|
+
return True, f"Installed ({boto3.__version__})", None
|
|
183
|
+
except ImportError:
|
|
184
|
+
return True, "Not installed", (
|
|
185
|
+
"Needed only for `narvy cloud-scan`. Run: "
|
|
186
|
+
"pip install narvy[cloud]"
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def run_doctor():
|
|
191
|
+
console.print(f"\n[bold]Narvy CLI Doctor[/bold] - {platform.system()} {platform.release()}, Python {sys.version.split()[0]}\n")
|
|
192
|
+
|
|
193
|
+
checks = [
|
|
194
|
+
("Java (JRE 11+)", _check_java),
|
|
195
|
+
("Disk write access (~/.narvy)", _check_write_access),
|
|
196
|
+
("Available memory (Android decompile)", _check_memory),
|
|
197
|
+
("Network (github.com, for jadx download)", _check_network),
|
|
198
|
+
("jadx (decompiler)", _check_jadx),
|
|
199
|
+
("semgrep (deep SAST engine)", _check_semgrep),
|
|
200
|
+
("icdump (iOS Obj-C introspection, optional)", _check_icdump),
|
|
201
|
+
("nuclei (web-scan, optional)", _check_nuclei),
|
|
202
|
+
("ssh/scp client (host-audit, optional)", _check_ssh_client),
|
|
203
|
+
("boto3 (cloud-scan, optional)", _check_boto3),
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
table = Table(show_header=True)
|
|
207
|
+
table.add_column("Check")
|
|
208
|
+
table.add_column("Status", justify="center")
|
|
209
|
+
table.add_column("Detail")
|
|
210
|
+
|
|
211
|
+
all_ok = True
|
|
212
|
+
fixes = []
|
|
213
|
+
optional_hints = []
|
|
214
|
+
for name, check_fn in checks:
|
|
215
|
+
ok, detail, fix = check_fn()
|
|
216
|
+
if not ok:
|
|
217
|
+
status = "[bold red]FAIL[/bold red]"
|
|
218
|
+
elif fix:
|
|
219
|
+
status = "[bold cyan]INFO[/bold cyan]"
|
|
220
|
+
else:
|
|
221
|
+
status = "[bold green]PASS[/bold green]"
|
|
222
|
+
# Detail text can contain '[...]' (an extras spec, nuclei's [INF]) which
|
|
223
|
+
# Rich would parse as markup and silently drop.
|
|
224
|
+
table.add_row(name, status, _rich_escape(detail))
|
|
225
|
+
if not ok:
|
|
226
|
+
all_ok = False
|
|
227
|
+
fixes.append((name, fix))
|
|
228
|
+
elif fix:
|
|
229
|
+
optional_hints.append((name, fix))
|
|
230
|
+
|
|
231
|
+
console.print(table)
|
|
232
|
+
|
|
233
|
+
if all_ok:
|
|
234
|
+
console.print("\n[bold green]Everything looks good. `narvy scan <apk|ipa|android-source-dir|ios-source-dir|web-source-dir>` should work.[/bold green]")
|
|
235
|
+
else:
|
|
236
|
+
console.print("\n[bold red]Found problems - fix these before scanning:[/bold red]")
|
|
237
|
+
for name, fix in fixes:
|
|
238
|
+
console.print(f"\n[bold]{name}:[/bold] {_rich_escape(fix)}")
|
|
239
|
+
|
|
240
|
+
for name, hint in optional_hints:
|
|
241
|
+
console.print(f"\n[bold cyan]{name} (optional):[/bold cyan] {_rich_escape(hint)}")
|
|
242
|
+
|
|
243
|
+
if not all_ok:
|
|
244
|
+
sys.exit(1)
|
narvy/host/__init__.py
ADDED
|
File without changes
|
narvy/host/audit.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""Run Lynis on a remote host over SSH and return normalized findings.
|
|
2
|
+
A sudo password is never sent, so full coverage needs a root key or NOPASSWD sudo."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
import uuid
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, Dict
|
|
10
|
+
|
|
11
|
+
from .lynis_bootstrap import (LynisBootstrapError, cleanup_lynis, ensure_lynis,
|
|
12
|
+
workdir_for_cmd)
|
|
13
|
+
from .lynis_parser import parse_lynis_report
|
|
14
|
+
from .ssh_exec import HostConn, SSHExecError, ssh_exec
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
LYNIS_TIMEOUT = 600
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class AuditOutcome:
|
|
23
|
+
ok: bool
|
|
24
|
+
findings: list
|
|
25
|
+
hardening_index: Any
|
|
26
|
+
meta: Dict[str, str]
|
|
27
|
+
error: str = ""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _lynis_prefix(privilege: str) -> str:
|
|
31
|
+
if privilege == "root":
|
|
32
|
+
return ""
|
|
33
|
+
if privilege == "nopasswd_sudo":
|
|
34
|
+
return "sudo -n "
|
|
35
|
+
return ""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _absent_kernel_modules(conn, prefix: str) -> set:
|
|
39
|
+
"""Return probed modules absent on this kernel; empty set on failure keeps every finding."""
|
|
40
|
+
probed = ("dccp", "sctp", "rds", "tipc", "usb-storage")
|
|
41
|
+
checks = "; ".join(
|
|
42
|
+
f'{prefix}modprobe -n -v {m} >/dev/null 2>&1 && echo "OK {m}" || echo "ABSENT {m}"'
|
|
43
|
+
for m in probed
|
|
44
|
+
)
|
|
45
|
+
try:
|
|
46
|
+
res = ssh_exec(conn, checks, timeout=45)
|
|
47
|
+
except SSHExecError:
|
|
48
|
+
logger.warning("audit_host: kernel module probe failed; keeping all findings")
|
|
49
|
+
return set()
|
|
50
|
+
absent = set()
|
|
51
|
+
for line in (res.stdout or "").splitlines():
|
|
52
|
+
parts = line.split()
|
|
53
|
+
if len(parts) == 2 and parts[0] == "ABSENT":
|
|
54
|
+
absent.add(parts[1])
|
|
55
|
+
return absent
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def detect_privilege(conn) -> str:
|
|
59
|
+
"""Probe the privilege actually held on the target: root, nopasswd_sudo or none."""
|
|
60
|
+
try:
|
|
61
|
+
r = ssh_exec(
|
|
62
|
+
conn,
|
|
63
|
+
"id -u; sudo -n true 2>/dev/null && echo SUDO_OK || echo SUDO_NO",
|
|
64
|
+
timeout=30,
|
|
65
|
+
)
|
|
66
|
+
except SSHExecError:
|
|
67
|
+
return "none"
|
|
68
|
+
out = (r.stdout or "").split()
|
|
69
|
+
uid = out[0] if out else ""
|
|
70
|
+
if uid == "0":
|
|
71
|
+
return "root"
|
|
72
|
+
if "SUDO_OK" in (r.stdout or ""):
|
|
73
|
+
return "nopasswd_sudo"
|
|
74
|
+
return "none"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def audit_host(conn: HostConn, privilege: str = "auto") -> AuditOutcome:
|
|
78
|
+
"""Run a Lynis audit on `conn`; `privilege` is downgraded to what the probe finds. Never raises: returns ok=False with a scrubbed error."""
|
|
79
|
+
report_path = f"/tmp/narvy_host_audit_{uuid.uuid4().hex[:12]}.dat"
|
|
80
|
+
|
|
81
|
+
detected = detect_privilege(conn)
|
|
82
|
+
if privilege == "auto":
|
|
83
|
+
privilege = detected
|
|
84
|
+
elif privilege != detected:
|
|
85
|
+
logger.info("audit_host: privilege %r requested, %r detected, using "
|
|
86
|
+
"detected", privilege, detected)
|
|
87
|
+
privilege = detected
|
|
88
|
+
prefix = _lynis_prefix(privilege)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
lynis_cmd, ephemeral = ensure_lynis(conn, prefix)
|
|
92
|
+
except SSHExecError as exc:
|
|
93
|
+
return AuditOutcome(False, [], None, {}, f"ssh_connect_failed: {exc}")
|
|
94
|
+
except LynisBootstrapError as exc:
|
|
95
|
+
return AuditOutcome(False, [], None, {}, f"lynis_unavailable: {exc}")
|
|
96
|
+
|
|
97
|
+
# umask 077 keeps the report 0600 on the target.
|
|
98
|
+
run_cmd = (
|
|
99
|
+
f"umask 077; {prefix}{lynis_cmd} audit system --quick --no-colors "
|
|
100
|
+
f"--report-file {report_path} >/dev/null 2>&1; echo RC=$?"
|
|
101
|
+
)
|
|
102
|
+
dat = ""
|
|
103
|
+
run_tail = ""
|
|
104
|
+
try:
|
|
105
|
+
try:
|
|
106
|
+
run = ssh_exec(conn, run_cmd, timeout=LYNIS_TIMEOUT)
|
|
107
|
+
run_tail = (run.stdout or "").strip()[-40:]
|
|
108
|
+
except SSHExecError as exc:
|
|
109
|
+
return AuditOutcome(False, [], None, {}, f"lynis_run_failed: {exc}")
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
cat = ssh_exec(conn, f"{prefix}cat {report_path} 2>/dev/null", timeout=60)
|
|
113
|
+
dat = cat.stdout or ""
|
|
114
|
+
except SSHExecError as exc:
|
|
115
|
+
return AuditOutcome(False, [], None, {}, f"report_read_failed: {exc}")
|
|
116
|
+
finally:
|
|
117
|
+
try:
|
|
118
|
+
ssh_exec(conn, f"{prefix}rm -f {report_path}", timeout=30)
|
|
119
|
+
except SSHExecError:
|
|
120
|
+
logger.warning("audit_host: could not remove remote report %s",
|
|
121
|
+
report_path)
|
|
122
|
+
if ephemeral:
|
|
123
|
+
wd = workdir_for_cmd(lynis_cmd)
|
|
124
|
+
if wd:
|
|
125
|
+
cleanup_lynis(conn, wd)
|
|
126
|
+
|
|
127
|
+
if "hardening_index=" not in dat and "warning[]=" not in dat:
|
|
128
|
+
return AuditOutcome(
|
|
129
|
+
False, [], None, {},
|
|
130
|
+
f"empty_or_invalid_report (lynis {run_tail}); "
|
|
131
|
+
f"privilege={privilege} may be insufficient")
|
|
132
|
+
|
|
133
|
+
# Lynis reports modules the kernel lacks, so confirm existence first.
|
|
134
|
+
absent_modules = _absent_kernel_modules(conn, prefix)
|
|
135
|
+
|
|
136
|
+
parsed = parse_lynis_report(dat, absent_modules=absent_modules)
|
|
137
|
+
|
|
138
|
+
narvy_findings: list = []
|
|
139
|
+
try:
|
|
140
|
+
from .narvy_checks import run_narvy_host_checks
|
|
141
|
+
narvy_findings = run_narvy_host_checks(conn, prefix)
|
|
142
|
+
except Exception: # noqa: BLE001
|
|
143
|
+
logger.warning("audit_host: narvy host checks failed; continuing with "
|
|
144
|
+
"Lynis baseline findings only", exc_info=True)
|
|
145
|
+
|
|
146
|
+
meta = dict(parsed["meta"])
|
|
147
|
+
meta["audit_privilege"] = privilege
|
|
148
|
+
meta["coverage"] = "partial" if privilege == "none" else "full"
|
|
149
|
+
meta["scan_engine"] = (
|
|
150
|
+
"Narvy internal host checks + Lynis (GPL-3.0) hardening baseline, "
|
|
151
|
+
"orchestrated by Narvy")
|
|
152
|
+
return AuditOutcome(
|
|
153
|
+
ok=True,
|
|
154
|
+
findings=list(parsed["findings"]) + list(narvy_findings),
|
|
155
|
+
hardening_index=parsed["hardening_index"],
|
|
156
|
+
meta=meta,
|
|
157
|
+
)
|