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
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Parse a Lynis `lynis-report.dat` into Narvy host-audit findings.
|
|
2
|
+
|
|
3
|
+
The `.dat` is a flat key=value file; repeated keys use a `key[]=` suffix.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
from .host_knowledge import lookup
|
|
12
|
+
|
|
13
|
+
ENGINE_NAME = "Lynis (GPL-3.0), orchestrated by Narvy"
|
|
14
|
+
|
|
15
|
+
# Canonical upstream reference for a Lynis control id.
|
|
16
|
+
LYNIS_REF_BASE = "https://cisofy.com/lynis/controls/"
|
|
17
|
+
|
|
18
|
+
_WARNING_DOWNGRADE = {
|
|
19
|
+
"KRNL-5830": "low",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# "LYNIS" is Lynis's own outdated-version notice, not a host control.
|
|
23
|
+
_SKIP_TEST_IDS = {"LYNIS"}
|
|
24
|
+
|
|
25
|
+
_TESTID_GROUP_CATEGORY = {
|
|
26
|
+
"ACCT": "Accounting & Auditing",
|
|
27
|
+
"AUTH": "Authentication",
|
|
28
|
+
"BANN": "Banners & Policy",
|
|
29
|
+
"BOOT": "Boot & Services",
|
|
30
|
+
"CONT": "Containers",
|
|
31
|
+
"CRYP": "Cryptography",
|
|
32
|
+
"DBS": "Databases",
|
|
33
|
+
"DEB": "Package Management",
|
|
34
|
+
"FILE": "Filesystem",
|
|
35
|
+
"FINT": "File Integrity",
|
|
36
|
+
"FIRE": "Firewall",
|
|
37
|
+
"HRDN": "Hardening",
|
|
38
|
+
"HTTP": "Web Server",
|
|
39
|
+
"INSE": "Insecure Services",
|
|
40
|
+
"KRNL": "Kernel",
|
|
41
|
+
"LOGG": "Logging",
|
|
42
|
+
"MACF": "Mandatory Access Control",
|
|
43
|
+
"MAIL": "Mail",
|
|
44
|
+
"MALW": "Malware Protection",
|
|
45
|
+
"NAME": "DNS & Naming",
|
|
46
|
+
"NETW": "Network",
|
|
47
|
+
"PHP": "PHP",
|
|
48
|
+
"PKGS": "Packages & Patching",
|
|
49
|
+
"PRNT": "Printing",
|
|
50
|
+
"SCHD": "Scheduled Tasks",
|
|
51
|
+
"SHLL": "Shell",
|
|
52
|
+
"SQD": "Squid Proxy",
|
|
53
|
+
"SSH": "SSH",
|
|
54
|
+
"STRG": "Storage",
|
|
55
|
+
"TIME": "Time Sync",
|
|
56
|
+
"TOOL": "Security Tooling",
|
|
57
|
+
"USB": "USB Devices",
|
|
58
|
+
"VIRT": "Virtualization",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _category_for(test_id: str) -> str:
|
|
63
|
+
group = (test_id or "").split("-", 1)[0].upper()
|
|
64
|
+
return _TESTID_GROUP_CATEGORY.get(group, "Host Hardening")
|
|
65
|
+
|
|
66
|
+
# TEST-ID -> (CWE, CIS control label). Unmapped ids still produce a finding.
|
|
67
|
+
_TESTID_MAP = {
|
|
68
|
+
"DBS-1820": ("CWE-306", "CIS 5.x Database Authentication"),
|
|
69
|
+
"PKGS-7392": ("CWE-1104", "CIS 1.9 Patch Management"),
|
|
70
|
+
"SSH-7408": ("CWE-16", "CIS 5.2 SSH Server Configuration"),
|
|
71
|
+
"AUTH-9328": ("CWE-521", "CIS 5.4 Password Policy"),
|
|
72
|
+
"FIRE-4513": ("CWE-1327", "CIS 3.5 Firewall Configuration"),
|
|
73
|
+
"BOOT-5122": ("CWE-284", "CIS 1.4 Bootloader Password"),
|
|
74
|
+
"KRNL-5820": ("CWE-284", "CIS 1.5 Kernel Hardening"),
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_SENSITIVE_LISTEN = {
|
|
78
|
+
"mongod": "MongoDB", "mysqld": "MySQL", "postgres": "PostgreSQL",
|
|
79
|
+
"redis-server": "Redis", "memcached": "Memcached",
|
|
80
|
+
"elasticsearch": "Elasticsearch", "mongos": "MongoDB",
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
# Loopback / link-local / RFC1918 = not exposed. 0.0.0.0 and :: (all interfaces) are handled separately.
|
|
84
|
+
_PRIVATE_ADDR_RE = re.compile(
|
|
85
|
+
r"^(127\.|::1$|\[::1\]|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|fe80:)"
|
|
86
|
+
)
|
|
87
|
+
_ALL_INTERFACES = {"0.0.0.0", "::", "*", "[::]"}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_lynis_report(dat_text: str,
|
|
91
|
+
absent_modules: Optional[set] = None) -> Dict[str, Any]:
|
|
92
|
+
"""Return {meta, findings, hardening_index} from the raw report text."""
|
|
93
|
+
meta: Dict[str, str] = {}
|
|
94
|
+
warnings: List[str] = []
|
|
95
|
+
suggestions: List[str] = []
|
|
96
|
+
listens: List[str] = []
|
|
97
|
+
|
|
98
|
+
for raw in dat_text.splitlines():
|
|
99
|
+
line = raw.strip()
|
|
100
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
101
|
+
continue
|
|
102
|
+
key, _, val = line.partition("=")
|
|
103
|
+
if key == "warning[]":
|
|
104
|
+
warnings.append(val)
|
|
105
|
+
elif key == "suggestion[]":
|
|
106
|
+
suggestions.append(val)
|
|
107
|
+
elif key == "network_listen[]":
|
|
108
|
+
listens.append(val)
|
|
109
|
+
elif key in ("hardening_index", "lynis_version", "os_fullname",
|
|
110
|
+
"hostname", "os_version"):
|
|
111
|
+
meta.setdefault(key, val)
|
|
112
|
+
|
|
113
|
+
exposed_services = _exposed_service_names(listens)
|
|
114
|
+
|
|
115
|
+
findings: List[Dict[str, Any]] = []
|
|
116
|
+
for w in warnings:
|
|
117
|
+
findings.append(_finding_from_pipe(w, kind="warning",
|
|
118
|
+
exposed_services=exposed_services))
|
|
119
|
+
for s in suggestions:
|
|
120
|
+
findings.append(_finding_from_pipe(s, kind="suggestion",
|
|
121
|
+
exposed_services=exposed_services))
|
|
122
|
+
findings.extend(_exposure_findings(listens))
|
|
123
|
+
|
|
124
|
+
# The EOL-OS control's text matches no _variant_of() pattern; attach the distro from meta.
|
|
125
|
+
os_evidence = meta.get("os_fullname") or meta.get("os_version") or ""
|
|
126
|
+
if os_evidence:
|
|
127
|
+
for f in findings:
|
|
128
|
+
if f and f.get("control_id") == "NARVY-HOST-OS-001":
|
|
129
|
+
f["title"] = f"{f['title']} ({os_evidence})"
|
|
130
|
+
f["discriminator"] = f"{f['control_id']}:{os_evidence}"
|
|
131
|
+
f["description"] = f"{f['description']}\n\nDetected: {os_evidence}"
|
|
132
|
+
|
|
133
|
+
seen = set()
|
|
134
|
+
clean: List[Dict[str, Any]] = []
|
|
135
|
+
for f in findings:
|
|
136
|
+
if not f:
|
|
137
|
+
continue
|
|
138
|
+
# Drop findings about kernel modules the host confirmed it lacks.
|
|
139
|
+
variant = (f.get("_variant") or "").strip()
|
|
140
|
+
if variant and variant in (absent_modules or set()):
|
|
141
|
+
continue
|
|
142
|
+
k = f.get("discriminator") or (f.get("test_id"), f.get("title"))
|
|
143
|
+
if k in seen:
|
|
144
|
+
continue
|
|
145
|
+
seen.add(k)
|
|
146
|
+
clean.append(f)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
hi = int(meta.get("hardening_index", "")) if meta.get("hardening_index") else None
|
|
150
|
+
except ValueError:
|
|
151
|
+
hi = None
|
|
152
|
+
|
|
153
|
+
return {"meta": meta, "hardening_index": hi, "findings": clean}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# TEST-ID -> the service token to look for in the listener list.
|
|
157
|
+
_EXPOSURE_SENSITIVE_CONTROLS = {
|
|
158
|
+
"DBS-1820": "mongod",
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _exposed_service_names(listens: List[str]) -> set:
|
|
163
|
+
"""Service names bound to a routable / all-interfaces address."""
|
|
164
|
+
exposed = set()
|
|
165
|
+
for l in listens:
|
|
166
|
+
parts = l.split("|")
|
|
167
|
+
if len(parts) < 4:
|
|
168
|
+
continue
|
|
169
|
+
addr, service = parts[2].strip(), parts[3].strip().lower()
|
|
170
|
+
host = _host_of(addr)
|
|
171
|
+
if host in _ALL_INTERFACES or not _PRIVATE_ADDR_RE.match(host):
|
|
172
|
+
exposed.add(service)
|
|
173
|
+
return exposed
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _adjust_for_exposure(test_id: str, severity: str,
|
|
177
|
+
exposed_services: set) -> tuple:
|
|
178
|
+
"""Downgrade a 'no authentication' finding when the service is loopback-only."""
|
|
179
|
+
svc = _EXPOSURE_SENSITIVE_CONTROLS.get(test_id)
|
|
180
|
+
if not svc or severity != "high":
|
|
181
|
+
return severity, ""
|
|
182
|
+
if any(svc in s for s in exposed_services):
|
|
183
|
+
return severity, ""
|
|
184
|
+
return "medium", (
|
|
185
|
+
"\n\nScope: this service is currently bound to loopback only, so it is "
|
|
186
|
+
"not reachable from the network. Severity is reduced accordingly. It "
|
|
187
|
+
"still matters: any local account, or a compromise of any application "
|
|
188
|
+
"on this host, reaches the data with no further authentication."
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _variant_of(text: str) -> str:
|
|
193
|
+
"""Pull the distinguishing token from a sibling suggestion so siblings don't collapse into one row."""
|
|
194
|
+
m = re.search(r"'([^']{1,40})'", text)
|
|
195
|
+
if m:
|
|
196
|
+
return m.group(1)
|
|
197
|
+
m = re.search(r"(/[a-z][a-z0-9_/.-]{1,30})", text)
|
|
198
|
+
if m:
|
|
199
|
+
return m.group(1)
|
|
200
|
+
m = re.search(r"\b(minimum|maximum)\b", text, re.I)
|
|
201
|
+
if m:
|
|
202
|
+
return m.group(1).lower()
|
|
203
|
+
return ""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _finding_from_pipe(val: str, *, kind: str,
|
|
207
|
+
exposed_services: Optional[set] = None) -> Optional[Dict[str, Any]]:
|
|
208
|
+
# TESTID|description|details|solution| -- split capped at 4 so a stray '|' in solution doesn't shift fields.
|
|
209
|
+
if val.endswith("|"):
|
|
210
|
+
val = val[:-1]
|
|
211
|
+
parts = val.split("|", 3)
|
|
212
|
+
test_id = parts[0].strip() if parts else ""
|
|
213
|
+
if not test_id or test_id.upper() in _SKIP_TEST_IDS:
|
|
214
|
+
return None
|
|
215
|
+
desc = parts[1].strip() if len(parts) > 1 else ""
|
|
216
|
+
details = _clean(parts[2]) if len(parts) > 2 else ""
|
|
217
|
+
solution_advice, solution_url = _parse_solution(parts[3] if len(parts) > 3 else "")
|
|
218
|
+
reference = solution_url or _lynis_ref(test_id)
|
|
219
|
+
|
|
220
|
+
# Prefer the curated KB entry; fall back to raw fields when absent.
|
|
221
|
+
kb = lookup(test_id)
|
|
222
|
+
raw_title = desc or test_id
|
|
223
|
+
|
|
224
|
+
if kb:
|
|
225
|
+
variant = _variant_of(f"{desc} {details}")
|
|
226
|
+
title = f"{kb.title} ({variant})" if variant else kb.title
|
|
227
|
+
description = kb.description
|
|
228
|
+
if variant:
|
|
229
|
+
description = f"{description}\n\nAffected: {variant}"
|
|
230
|
+
severity, scope_note = _adjust_for_exposure(
|
|
231
|
+
test_id, kb.severity, exposed_services or set())
|
|
232
|
+
if scope_note:
|
|
233
|
+
description = description + scope_note
|
|
234
|
+
return {
|
|
235
|
+
"engine": ENGINE_NAME,
|
|
236
|
+
"test_id": test_id,
|
|
237
|
+
"control_id": kb.control_id,
|
|
238
|
+
"title": title,
|
|
239
|
+
"severity": severity,
|
|
240
|
+
"category": _category_for(test_id),
|
|
241
|
+
"description": description,
|
|
242
|
+
"details": "",
|
|
243
|
+
"remediation": kb.remediation,
|
|
244
|
+
"cwe": kb.cwe or None,
|
|
245
|
+
"cis": kb.cis or None,
|
|
246
|
+
"location": kb.location or "",
|
|
247
|
+
"reference": reference,
|
|
248
|
+
"discriminator": f"{kb.control_id}:{variant}" if variant else kb.control_id,
|
|
249
|
+
"_variant": variant,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
sev = _WARNING_DOWNGRADE.get(test_id, "medium") if kind == "warning" else "low"
|
|
253
|
+
cwe, cis = _TESTID_MAP.get(test_id, (None, None))
|
|
254
|
+
# Generic control-id group from the test-id prefix.
|
|
255
|
+
group = test_id.split("-", 1)[0].upper()
|
|
256
|
+
generic_id = f"NARVY-HOST-GEN-{group}"
|
|
257
|
+
return {
|
|
258
|
+
"engine": ENGINE_NAME,
|
|
259
|
+
"test_id": test_id,
|
|
260
|
+
"control_id": generic_id,
|
|
261
|
+
"title": raw_title,
|
|
262
|
+
"severity": sev,
|
|
263
|
+
"category": _category_for(test_id),
|
|
264
|
+
"description": desc,
|
|
265
|
+
"details": details,
|
|
266
|
+
"remediation": solution_advice or (
|
|
267
|
+
"Review this control against your hardening baseline and apply your "
|
|
268
|
+
"platform's recommended configuration."
|
|
269
|
+
),
|
|
270
|
+
"cwe": cwe,
|
|
271
|
+
"cis": cis,
|
|
272
|
+
"location": "",
|
|
273
|
+
"reference": reference,
|
|
274
|
+
"discriminator": f"{generic_id}:{raw_title[:60]}",
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _exposure_findings(listens: List[str]) -> List[Dict[str, Any]]:
|
|
279
|
+
out: List[Dict[str, Any]] = []
|
|
280
|
+
for l in listens:
|
|
281
|
+
# source|proto|address:port|service|
|
|
282
|
+
parts = l.split("|")
|
|
283
|
+
if len(parts) < 4:
|
|
284
|
+
continue
|
|
285
|
+
proto, addr, service = parts[1].strip(), parts[2].strip(), parts[3].strip()
|
|
286
|
+
host = _host_of(addr)
|
|
287
|
+
if host not in _ALL_INTERFACES and _PRIVATE_ADDR_RE.match(host):
|
|
288
|
+
continue
|
|
289
|
+
svc_key = next((k for k in _SENSITIVE_LISTEN if k in service.lower()), None)
|
|
290
|
+
if not svc_key:
|
|
291
|
+
continue
|
|
292
|
+
name = _SENSITIVE_LISTEN[svc_key]
|
|
293
|
+
out.append({
|
|
294
|
+
"engine": ENGINE_NAME,
|
|
295
|
+
"test_id": "NETW-EXPOSURE",
|
|
296
|
+
"control_id": "NARVY-HOST-NETW-900",
|
|
297
|
+
"discriminator": f"NARVY-HOST-NETW-900:{addr}",
|
|
298
|
+
"title": f"{name} listening on a routable address ({addr})",
|
|
299
|
+
"severity": "medium",
|
|
300
|
+
"category": "network-exposure",
|
|
301
|
+
"description": (
|
|
302
|
+
f"{name} ({service}) is bound to {addr}/{proto}, reachable "
|
|
303
|
+
f"beyond localhost. Confirm authentication and firewall rules."
|
|
304
|
+
),
|
|
305
|
+
"details": l,
|
|
306
|
+
"remediation": (
|
|
307
|
+
"Bind the service to 127.0.0.1 or a private interface, require "
|
|
308
|
+
"authentication, and restrict access with a host firewall."
|
|
309
|
+
),
|
|
310
|
+
"cwe": "CWE-668",
|
|
311
|
+
"cis": "CIS 3.x Network Configuration",
|
|
312
|
+
"location": f"listen:{addr}",
|
|
313
|
+
})
|
|
314
|
+
return out
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _host_of(addr: str) -> str:
|
|
318
|
+
"""Extract the host part of a listen address, IPv6-safe."""
|
|
319
|
+
a = addr.strip()
|
|
320
|
+
if a.startswith("["):
|
|
321
|
+
return a[: a.find("]") + 1] if "]" in a else a
|
|
322
|
+
if a.count(":") > 1:
|
|
323
|
+
return a
|
|
324
|
+
host = a.rsplit(":", 1)[0] if ":" in a else a
|
|
325
|
+
return host.split("%", 1)[0]
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _clean(s: str) -> str:
|
|
329
|
+
s = (s or "").strip()
|
|
330
|
+
s = s.rstrip("|").strip()
|
|
331
|
+
# Fields are directive-tokened: `text:` is advice, `url:` a link. Strip the prefix, keep the value.
|
|
332
|
+
low = s.lower()
|
|
333
|
+
if low.startswith("text:"):
|
|
334
|
+
s = s[5:].strip()
|
|
335
|
+
elif low.startswith("url:"):
|
|
336
|
+
s = s[4:].strip()
|
|
337
|
+
return "" if s in ("-", "") else s
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _parse_solution(raw: str) -> tuple:
|
|
341
|
+
"""Split a Lynis solution field (directive-tokened `text:`/`url:`) into (advice, reference url)."""
|
|
342
|
+
s = (raw or "").strip().rstrip("|").strip()
|
|
343
|
+
if not s or s == "-":
|
|
344
|
+
return "", ""
|
|
345
|
+
low = s.lower()
|
|
346
|
+
if low.startswith("url:"):
|
|
347
|
+
return "", s[4:].strip()
|
|
348
|
+
if low.startswith("text:"):
|
|
349
|
+
return s[5:].strip(), ""
|
|
350
|
+
return s, ""
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _lynis_ref(test_id: str) -> str:
|
|
354
|
+
"""Canonical upstream reference URL for a Lynis control id."""
|
|
355
|
+
tid = (test_id or "").strip()
|
|
356
|
+
if not tid:
|
|
357
|
+
return ""
|
|
358
|
+
return f"{LYNIS_REF_BASE}{tid}/"
|