mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-18 16:36:27 +00:00
Update lynis
This commit is contained in:
@@ -3147,19 +3147,44 @@ ${(report.sections && report.sections.length > 0) ? `
|
|||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Quick Status</p>
|
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Quick Status</p>
|
||||||
{[
|
{[
|
||||||
{ label: "Firewall", ok: lynisReport.firewall_active },
|
{
|
||||||
{ label: "Malware Scanner", ok: lynisReport.malware_scanner },
|
label: "Firewall",
|
||||||
{ label: "No Critical Warnings", ok: lynisReport.warnings.length === 0 },
|
ok: lynisReport.firewall_active,
|
||||||
{ label: "Hardening Score >= 70", ok: (lynisReport.hardening_index || 0) >= 70 },
|
passText: "Active",
|
||||||
].map((item) => (
|
failText: "Inactive",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Malware Scanner",
|
||||||
|
ok: lynisReport.malware_scanner,
|
||||||
|
passText: "Installed",
|
||||||
|
failText: "Not Installed",
|
||||||
|
isWarning: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Warnings",
|
||||||
|
ok: lynisReport.warnings.length === 0,
|
||||||
|
passText: "None",
|
||||||
|
failText: `${lynisReport.warnings.length} found`,
|
||||||
|
isWarning: lynisReport.warnings.length > 0 && lynisReport.warnings.length <= 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Hardening Score",
|
||||||
|
ok: (lynisReport.hardening_index || 0) >= 70,
|
||||||
|
passText: `${lynisReport.hardening_index || 0}/100`,
|
||||||
|
failText: `${lynisReport.hardening_index || 0}/100 (< 70)`,
|
||||||
|
isWarning: (lynisReport.hardening_index || 0) >= 50,
|
||||||
|
},
|
||||||
|
].map((item) => {
|
||||||
|
const color = item.ok ? "green" : item.isWarning ? "yellow" : "red"
|
||||||
|
return (
|
||||||
<div key={item.label} className="flex items-center gap-2 px-3 py-1.5 rounded bg-muted/20">
|
<div key={item.label} className="flex items-center gap-2 px-3 py-1.5 rounded bg-muted/20">
|
||||||
<div className={`w-2 h-2 rounded-full ${item.ok ? "bg-green-500" : "bg-red-500"}`} />
|
<div className={`w-2 h-2 rounded-full ${color === "green" ? "bg-green-500" : color === "yellow" ? "bg-yellow-500" : "bg-red-500"}`} />
|
||||||
<span className="text-xs">{item.label}</span>
|
<span className="text-xs">{item.label}</span>
|
||||||
<span className={`ml-auto text-[10px] font-bold ${item.ok ? "text-green-500" : "text-red-500"}`}>
|
<span className={`ml-auto text-[10px] font-bold ${color === "green" ? "text-green-500" : color === "yellow" ? "text-yellow-500" : "text-red-500"}`}>
|
||||||
{item.ok ? "PASS" : "FAIL"}
|
{item.ok ? item.passText : item.failText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
)})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1100,18 +1100,70 @@ def run_lynis_audit():
|
|||||||
if os.path.isfile(report_file):
|
if os.path.isfile(report_file):
|
||||||
os.remove(report_file)
|
os.remove(report_file)
|
||||||
|
|
||||||
rc, out, err = _run_cmd(
|
# Capture full formatted output. Lynis suppresses its nice
|
||||||
[lynis_cmd, "audit", "system", "--no-colors", "--quick"],
|
# formatted output ([+] sections) when stdout is not a tty.
|
||||||
timeout=600
|
# Use 'script' to simulate a terminal so Lynis outputs everything.
|
||||||
|
output_log = "/var/log/lynis-output.log"
|
||||||
|
rc = -1
|
||||||
|
out = ""
|
||||||
|
err = ""
|
||||||
|
|
||||||
|
# Method 1: Use 'script -qc' (simulates tty)
|
||||||
|
try:
|
||||||
|
script_cmd = [
|
||||||
|
"script", "-qec",
|
||||||
|
f"{lynis_cmd} audit system --no-colors --quick",
|
||||||
|
output_log
|
||||||
|
]
|
||||||
|
result = subprocess.run(
|
||||||
|
script_cmd,
|
||||||
|
capture_output=True, text=True, timeout=600,
|
||||||
|
env={**os.environ, "TERM": "dumb"}
|
||||||
|
)
|
||||||
|
rc = result.returncode
|
||||||
|
out = result.stdout.strip()
|
||||||
|
err = result.stderr.strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# If script failed or output file is too small, try direct method
|
||||||
|
output_ok = (
|
||||||
|
os.path.isfile(output_log)
|
||||||
|
and os.path.getsize(output_log) > 500
|
||||||
)
|
)
|
||||||
# Save stdout output for section parsing
|
if not output_ok:
|
||||||
if out:
|
|
||||||
try:
|
try:
|
||||||
with open("/var/log/lynis-output.log", "w") as fout:
|
rc, out, err = _run_cmd(
|
||||||
fout.write(out)
|
[lynis_cmd, "audit", "system", "--no-colors", "--quick"],
|
||||||
|
timeout=600
|
||||||
|
)
|
||||||
|
if out:
|
||||||
|
with open(output_log, "w") as fout:
|
||||||
|
fout.write(out)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
if rc == 0:
|
|
||||||
|
# Clean ANSI escape codes and control chars from output file
|
||||||
|
if os.path.isfile(output_log):
|
||||||
|
try:
|
||||||
|
import re as _re
|
||||||
|
with open(output_log, 'r', errors='replace') as fout:
|
||||||
|
raw = fout.read()
|
||||||
|
cleaned = _re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', raw)
|
||||||
|
cleaned = _re.sub(r'\x1b\([A-Z]', '', cleaned)
|
||||||
|
cleaned = cleaned.replace('\r', '')
|
||||||
|
# Remove 'Script started/done' lines added by script cmd
|
||||||
|
lines = cleaned.splitlines()
|
||||||
|
lines = [l for l in lines if not l.startswith('Script started') and not l.startswith('Script done')]
|
||||||
|
cleaned = '\n'.join(lines)
|
||||||
|
with open(output_log, 'w') as fout:
|
||||||
|
fout.write(cleaned)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Lynis returns the hardening index as exit code (e.g. 65)
|
||||||
|
# Any non-negative code means the audit ran successfully
|
||||||
|
if rc >= 0:
|
||||||
_lynis_audit_progress = "completed"
|
_lynis_audit_progress = "completed"
|
||||||
else:
|
else:
|
||||||
_lynis_audit_progress = f"error: {err[:200] if err else 'unknown error'}"
|
_lynis_audit_progress = f"error: {err[:200] if err else 'unknown error'}"
|
||||||
@@ -1140,7 +1192,9 @@ def parse_lynis_report():
|
|||||||
Returns a dict with all audit findings.
|
Returns a dict with all audit findings.
|
||||||
"""
|
"""
|
||||||
report_file = "/var/log/lynis-report.dat"
|
report_file = "/var/log/lynis-report.dat"
|
||||||
if not os.path.isfile(report_file):
|
output_file = "/var/log/lynis-output.log"
|
||||||
|
# Need at least one data source
|
||||||
|
if not os.path.isfile(report_file) and not os.path.isfile(output_file):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
report = {
|
report = {
|
||||||
@@ -1167,29 +1221,30 @@ def parse_lynis_report():
|
|||||||
warnings_raw = []
|
warnings_raw = []
|
||||||
suggestions_raw = []
|
suggestions_raw = []
|
||||||
|
|
||||||
try:
|
if os.path.isfile(report_file):
|
||||||
with open(report_file, 'r') as f:
|
try:
|
||||||
for line in f:
|
with open(report_file, 'r') as f:
|
||||||
line = line.strip()
|
for line in f:
|
||||||
if not line or line.startswith("#") or line.startswith("["):
|
line = line.strip()
|
||||||
continue
|
if not line or line.startswith("#") or line.startswith("["):
|
||||||
|
continue
|
||||||
|
|
||||||
if "=" not in line:
|
if "=" not in line:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
key, _, value = line.partition("=")
|
key, _, value = line.partition("=")
|
||||||
key = key.strip()
|
key = key.strip()
|
||||||
value = value.strip()
|
value = value.strip()
|
||||||
|
|
||||||
if key == "warning[]":
|
if key == "warning[]":
|
||||||
warnings_raw.append(value)
|
warnings_raw.append(value)
|
||||||
elif key == "suggestion[]":
|
elif key == "suggestion[]":
|
||||||
suggestions_raw.append(value)
|
suggestions_raw.append(value)
|
||||||
else:
|
else:
|
||||||
# Last value wins (some keys appear multiple times)
|
# Last value wins (some keys appear multiple times)
|
||||||
raw_data[key] = value
|
raw_data[key] = value
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
pass # Continue with output.log data
|
||||||
|
|
||||||
# Map known fields (Lynis uses varied naming across versions)
|
# Map known fields (Lynis uses varied naming across versions)
|
||||||
report["datetime_start"] = raw_data.get("report_datetime_start", "")
|
report["datetime_start"] = raw_data.get("report_datetime_start", "")
|
||||||
@@ -1331,30 +1386,52 @@ def parse_lynis_report():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Detect sub-results: " Result: found 35 running services"
|
# Detect sub-results: " Result: found 35 running services"
|
||||||
result_match = re.match(r'^[\s]+Result:\s+(.+)', stripped)
|
# After strip(), line becomes "Result: ..." (no leading spaces)
|
||||||
if result_match and current_section and current_checks:
|
if stripped.startswith("Result:") and current_section and current_checks:
|
||||||
current_checks[-1]["detail"] = result_match.group(1).strip()
|
detail = stripped[7:].strip()
|
||||||
|
if detail:
|
||||||
|
current_checks[-1]["detail"] = detail
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Fallback data extraction
|
# Extract key data from the info block and summary
|
||||||
if not report["hardening_index"] and "Hardening index" in stripped:
|
# Format: "Key: value" or "Key : value"
|
||||||
m = re.search(r'Hardening index\s*:\s*\[?(\d+)\]?', stripped)
|
if ":" in stripped:
|
||||||
if m:
|
if not report["hardening_index"] and "Hardening index" in stripped:
|
||||||
report["hardening_index"] = int(m.group(1))
|
m = re.search(r'Hardening index\s*:\s*(\d+)', stripped)
|
||||||
if report["tests_performed"] == 0 and "Tests performed" in stripped:
|
if m:
|
||||||
m = re.search(r'Tests performed\s*:\s*(\d+)', stripped)
|
report["hardening_index"] = int(m.group(1))
|
||||||
if m:
|
elif report["tests_performed"] == 0 and "Tests performed" in stripped:
|
||||||
report["tests_performed"] = int(m.group(1))
|
m = re.search(r'Tests performed\s*:\s*(\d+)', stripped)
|
||||||
if not report["kernel_version"] and "Kernel version" in stripped:
|
if m:
|
||||||
m = re.search(r'Kernel version\s*:\s*(.+)', stripped)
|
report["tests_performed"] = int(m.group(1))
|
||||||
if m:
|
elif not report["kernel_version"] and "Kernel version" in stripped:
|
||||||
report["kernel_version"] = m.group(1).strip()
|
m = re.search(r'Kernel version\s*:\s*(.+)', stripped)
|
||||||
if not report["hostname"] and "Hostname" in stripped and ":" in stripped:
|
if m:
|
||||||
m = re.search(r'Hostname\s*:\s*(.+)', stripped)
|
report["kernel_version"] = m.group(1).strip()
|
||||||
if m:
|
elif not report["hostname"] and stripped.startswith("Hostname"):
|
||||||
val = m.group(1).strip()
|
m = re.search(r'Hostname\s*:\s*(.+)', stripped)
|
||||||
if val and val != "N/A":
|
if m:
|
||||||
report["hostname"] = val
|
val = m.group(1).strip()
|
||||||
|
if val and val != "N/A":
|
||||||
|
report["hostname"] = val
|
||||||
|
elif not report["os_name"] and "Operating system name" in stripped:
|
||||||
|
m = re.search(r'Operating system name\s*:\s*(.+)', stripped)
|
||||||
|
if m:
|
||||||
|
report["os_name"] = m.group(1).strip()
|
||||||
|
elif not report["os_version"] and "Operating system version" in stripped:
|
||||||
|
m = re.search(r'Operating system version\s*:\s*(.+)', stripped)
|
||||||
|
if m:
|
||||||
|
report["os_version"] = m.group(1).strip()
|
||||||
|
elif not report["os_fullname"] and "Operating system:" in stripped:
|
||||||
|
m = re.search(r'Operating system\s*:\s*(.+)', stripped)
|
||||||
|
if m:
|
||||||
|
report["os_fullname"] = m.group(1).strip()
|
||||||
|
elif not report["lynis_version"] and "Program version" in stripped:
|
||||||
|
m = re.search(r'Program version\s*:\s*(.+)', stripped)
|
||||||
|
if m:
|
||||||
|
report["lynis_version"] = m.group(1).strip()
|
||||||
|
elif not report["datetime_start"] and "report_datetime_start" in stripped:
|
||||||
|
pass # already from .dat
|
||||||
|
|
||||||
# Save last section
|
# Save last section
|
||||||
if current_section and current_checks:
|
if current_section and current_checks:
|
||||||
@@ -1414,101 +1491,122 @@ def parse_lynis_report():
|
|||||||
if report["malware_scanner"]:
|
if report["malware_scanner"]:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Extract warnings/suggestions from stdout if report.dat had none
|
# Always parse lynis-output.log for warnings, suggestions, software
|
||||||
# The stdout format is:
|
# components. The report.dat is often sparse/empty on many systems.
|
||||||
# Warnings (5):
|
output_file = "/var/log/lynis-output.log"
|
||||||
# ! Warning text [TEST-ID]
|
_log = output_file if os.path.isfile(output_file) else "/var/log/lynis.log"
|
||||||
# Suggestions (42):
|
if os.path.isfile(_log):
|
||||||
# * Suggestion text [TEST-ID]
|
try:
|
||||||
# Also extract "Software components" section for firewall/malware status
|
import re
|
||||||
_need_warnings = len(report["warnings"]) == 0
|
with open(_log, 'r') as f:
|
||||||
_need_suggestions = len(report["suggestions"]) == 0
|
stdout_lines = f.readlines()
|
||||||
if _need_warnings or _need_suggestions:
|
|
||||||
output_file = "/var/log/lynis-output.log"
|
|
||||||
_log = output_file if os.path.isfile(output_file) else "/var/log/lynis.log"
|
|
||||||
if os.path.isfile(_log):
|
|
||||||
try:
|
|
||||||
import re
|
|
||||||
with open(_log, 'r') as f:
|
|
||||||
stdout_lines = f.readlines()
|
|
||||||
|
|
||||||
in_warnings = False
|
in_warnings = False
|
||||||
in_suggestions = False
|
in_suggestions = False
|
||||||
in_software = False
|
in_software = False
|
||||||
stdout_warnings = []
|
stdout_warnings = []
|
||||||
stdout_suggestions = []
|
stdout_suggestions = []
|
||||||
|
last_suggestion = None
|
||||||
|
|
||||||
for sline in stdout_lines:
|
for sline in stdout_lines:
|
||||||
sline = sline.rstrip('\n')
|
sline = sline.rstrip('\n')
|
||||||
sstripped = sline.strip()
|
sstripped = sline.strip()
|
||||||
|
|
||||||
# Detect "Warnings (N):" header
|
# Detect "Warnings (N):" header
|
||||||
if re.match(r'^Warnings\s*\(\d+\)\s*:', sstripped):
|
if re.match(r'^Warnings\s*\(\d+\)\s*:', sstripped):
|
||||||
in_warnings = True
|
in_warnings = True
|
||||||
in_suggestions = False
|
in_suggestions = False
|
||||||
in_software = False
|
in_software = False
|
||||||
continue
|
last_suggestion = None
|
||||||
# Detect "Suggestions (N):" header
|
continue
|
||||||
if re.match(r'^Suggestions\s*\(\d+\)\s*:', sstripped):
|
# Detect "Suggestions (N):" header
|
||||||
in_suggestions = True
|
if re.match(r'^Suggestions\s*\(\d+\)\s*:', sstripped):
|
||||||
in_warnings = False
|
in_suggestions = True
|
||||||
in_software = False
|
in_warnings = False
|
||||||
continue
|
in_software = False
|
||||||
# Detect "Software components:" section
|
last_suggestion = None
|
||||||
if "Software components:" in sstripped:
|
continue
|
||||||
in_software = True
|
# Detect "Software components:" section
|
||||||
in_warnings = False
|
if "Software components:" in sstripped:
|
||||||
in_suggestions = False
|
in_software = True
|
||||||
continue
|
in_warnings = False
|
||||||
# End of sections on major separators
|
in_suggestions = False
|
||||||
if sstripped.startswith('==='):
|
last_suggestion = None
|
||||||
in_warnings = False
|
continue
|
||||||
in_suggestions = False
|
# End of sections on major separators
|
||||||
in_software = False
|
if sstripped.startswith('==='):
|
||||||
continue
|
in_warnings = False
|
||||||
|
in_suggestions = False
|
||||||
|
in_software = False
|
||||||
|
last_suggestion = None
|
||||||
|
continue
|
||||||
|
|
||||||
# Parse "Software components" for firewall/malware
|
# Parse "Software components" for firewall/malware
|
||||||
# Format: "- Firewall [V]" or "[X]"
|
# Format: "- Firewall [V]" or "[X]"
|
||||||
if in_software:
|
if in_software:
|
||||||
sw_match = re.match(r'^-\s+(.+?)\s+\[([VX])\]', sstripped)
|
sw_match = re.match(r'^-\s+(.+?)\s+\[([VX])\]', sstripped)
|
||||||
if sw_match:
|
if sw_match:
|
||||||
sw_name = sw_match.group(1).strip().lower()
|
sw_name = sw_match.group(1).strip().lower()
|
||||||
sw_status = sw_match.group(2)
|
sw_status = sw_match.group(2)
|
||||||
if "firewall" in sw_name and sw_status == "V":
|
if "firewall" in sw_name and sw_status == "V":
|
||||||
report["firewall_active"] = True
|
report["firewall_active"] = True
|
||||||
if "malware" in sw_name and sw_status == "V":
|
if "malware" in sw_name and sw_status == "V":
|
||||||
report["malware_scanner"] = True
|
report["malware_scanner"] = True
|
||||||
|
|
||||||
# Parse warning lines: "! Warning text [TEST-ID]"
|
# Parse warning lines: "! Warning text [TEST-ID]"
|
||||||
if in_warnings and sstripped.startswith('!'):
|
if in_warnings and sstripped.startswith('!'):
|
||||||
wm = re.match(r'^!\s+(.+?)\s*\[([A-Z0-9_-]+)\]\s*$', sstripped)
|
wm = re.match(r'^!\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped)
|
||||||
if wm:
|
if wm:
|
||||||
stdout_warnings.append({
|
stdout_warnings.append({
|
||||||
"test_id": wm.group(2),
|
"test_id": wm.group(2),
|
||||||
"severity": "Warning",
|
"severity": "Warning",
|
||||||
"description": wm.group(1).strip(),
|
"description": wm.group(1).strip(),
|
||||||
"solution": "",
|
"solution": "",
|
||||||
})
|
})
|
||||||
|
|
||||||
# Parse suggestion lines: "* Suggestion text [TEST-ID]"
|
# Parse suggestion lines: "* Suggestion text [TEST-ID]"
|
||||||
if in_suggestions and sstripped.startswith('*'):
|
if in_suggestions:
|
||||||
sm = re.match(r'^\*\s+(.+?)\s*\[([A-Z0-9_-]+)\]\s*$', sstripped)
|
if sstripped.startswith('*'):
|
||||||
|
sm = re.match(r'^\*\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped)
|
||||||
if sm:
|
if sm:
|
||||||
stdout_suggestions.append({
|
last_suggestion = {
|
||||||
"test_id": sm.group(2),
|
"test_id": sm.group(2),
|
||||||
"description": sm.group(1).strip(),
|
"description": sm.group(1).strip(),
|
||||||
"solution": "",
|
"solution": "",
|
||||||
"details": "",
|
"details": "",
|
||||||
})
|
}
|
||||||
|
stdout_suggestions.append(last_suggestion)
|
||||||
|
elif last_suggestion and sstripped.startswith('- Details'):
|
||||||
|
dm = re.match(r'^-\s*Details\s*:\s*(.+)', sstripped)
|
||||||
|
if dm:
|
||||||
|
last_suggestion["details"] = dm.group(1).strip()
|
||||||
|
elif last_suggestion and sstripped.startswith('- Solution'):
|
||||||
|
sm2 = re.match(r'^-\s*Solution\s*:\s*(.+)', sstripped)
|
||||||
|
if sm2:
|
||||||
|
last_suggestion["solution"] = sm2.group(1).strip()
|
||||||
|
|
||||||
# Use stdout warnings/suggestions if report.dat had none
|
# Use stdout data if report.dat had none
|
||||||
if _need_warnings and stdout_warnings:
|
if len(report["warnings"]) == 0 and stdout_warnings:
|
||||||
report["warnings"] = stdout_warnings
|
report["warnings"] = stdout_warnings
|
||||||
if _need_suggestions and stdout_suggestions:
|
if len(report["suggestions"]) == 0 and stdout_suggestions:
|
||||||
report["suggestions"] = stdout_suggestions
|
report["suggestions"] = stdout_suggestions
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
# Fallback: datetime from file modification time
|
||||||
|
if not report["datetime_start"]:
|
||||||
|
for fpath in ["/var/log/lynis-output.log", "/var/log/lynis-report.dat"]:
|
||||||
|
if os.path.isfile(fpath):
|
||||||
|
try:
|
||||||
|
import time
|
||||||
|
mtime = os.path.getmtime(fpath)
|
||||||
|
report["datetime_start"] = time.strftime(
|
||||||
|
"%Y-%m-%d %H:%M", time.localtime(mtime)
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
# Fallback: get kernel from uname if still empty
|
# Fallback: get kernel from uname if still empty
|
||||||
if not report["kernel_version"]:
|
if not report["kernel_version"]:
|
||||||
|
|||||||
Reference in New Issue
Block a user