Update lynis

This commit is contained in:
MacRimi
2026-02-08 19:24:40 +01:00
parent 6d0a07f212
commit 22d570b024
2 changed files with 265 additions and 142 deletions

View File

@@ -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>
)} )}

View File

@@ -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"]: