From 22d570b02476e61b55ae54d51e13e27bad15c32b Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sun, 8 Feb 2026 19:24:40 +0100 Subject: [PATCH] Update lynis --- AppImage/components/security.tsx | 43 +++- AppImage/scripts/security_manager.py | 364 +++++++++++++++++---------- 2 files changed, 265 insertions(+), 142 deletions(-) diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index 7ac3b8d1..cc19d646 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -3147,19 +3147,44 @@ ${(report.sections && report.sections.length > 0) ? `

Quick Status

{[ - { label: "Firewall", ok: lynisReport.firewall_active }, - { label: "Malware Scanner", ok: lynisReport.malware_scanner }, - { label: "No Critical Warnings", ok: lynisReport.warnings.length === 0 }, - { label: "Hardening Score >= 70", ok: (lynisReport.hardening_index || 0) >= 70 }, - ].map((item) => ( + { + label: "Firewall", + ok: lynisReport.firewall_active, + passText: "Active", + 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 (
-
+
{item.label} - - {item.ok ? "PASS" : "FAIL"} + + {item.ok ? item.passText : item.failText}
- ))} + )})}
)} diff --git a/AppImage/scripts/security_manager.py b/AppImage/scripts/security_manager.py index d7de97a3..44536482 100644 --- a/AppImage/scripts/security_manager.py +++ b/AppImage/scripts/security_manager.py @@ -1100,18 +1100,70 @@ def run_lynis_audit(): if os.path.isfile(report_file): os.remove(report_file) - rc, out, err = _run_cmd( - [lynis_cmd, "audit", "system", "--no-colors", "--quick"], - timeout=600 + # Capture full formatted output. Lynis suppresses its nice + # formatted output ([+] sections) when stdout is not a tty. + # 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 out: + if not output_ok: try: - with open("/var/log/lynis-output.log", "w") as fout: - fout.write(out) + rc, out, err = _run_cmd( + [lynis_cmd, "audit", "system", "--no-colors", "--quick"], + timeout=600 + ) + if out: + with open(output_log, "w") as fout: + fout.write(out) except Exception: 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" else: _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. """ 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 report = { @@ -1167,29 +1221,30 @@ def parse_lynis_report(): warnings_raw = [] suggestions_raw = [] - try: - with open(report_file, 'r') as f: - for line in f: - line = line.strip() - if not line or line.startswith("#") or line.startswith("["): - continue + if os.path.isfile(report_file): + try: + with open(report_file, 'r') as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or line.startswith("["): + continue - if "=" not in line: - continue + if "=" not in line: + continue - key, _, value = line.partition("=") - key = key.strip() - value = value.strip() + key, _, value = line.partition("=") + key = key.strip() + value = value.strip() - if key == "warning[]": - warnings_raw.append(value) - elif key == "suggestion[]": - suggestions_raw.append(value) - else: - # Last value wins (some keys appear multiple times) - raw_data[key] = value - except Exception: - return None + if key == "warning[]": + warnings_raw.append(value) + elif key == "suggestion[]": + suggestions_raw.append(value) + else: + # Last value wins (some keys appear multiple times) + raw_data[key] = value + except Exception: + pass # Continue with output.log data # Map known fields (Lynis uses varied naming across versions) report["datetime_start"] = raw_data.get("report_datetime_start", "") @@ -1331,30 +1386,52 @@ def parse_lynis_report(): continue # Detect sub-results: " Result: found 35 running services" - result_match = re.match(r'^[\s]+Result:\s+(.+)', stripped) - if result_match and current_section and current_checks: - current_checks[-1]["detail"] = result_match.group(1).strip() + # After strip(), line becomes "Result: ..." (no leading spaces) + if stripped.startswith("Result:") and current_section and current_checks: + detail = stripped[7:].strip() + if detail: + current_checks[-1]["detail"] = detail continue - # Fallback data extraction - if not report["hardening_index"] and "Hardening index" in stripped: - m = re.search(r'Hardening index\s*:\s*\[?(\d+)\]?', stripped) - if m: - report["hardening_index"] = int(m.group(1)) - if report["tests_performed"] == 0 and "Tests performed" in stripped: - m = re.search(r'Tests performed\s*:\s*(\d+)', stripped) - if m: - report["tests_performed"] = int(m.group(1)) - if not report["kernel_version"] and "Kernel version" in stripped: - m = re.search(r'Kernel version\s*:\s*(.+)', stripped) - if m: - report["kernel_version"] = m.group(1).strip() - if not report["hostname"] and "Hostname" in stripped and ":" in stripped: - m = re.search(r'Hostname\s*:\s*(.+)', stripped) - if m: - val = m.group(1).strip() - if val and val != "N/A": - report["hostname"] = val + # Extract key data from the info block and summary + # Format: "Key: value" or "Key : value" + if ":" in stripped: + if not report["hardening_index"] and "Hardening index" in stripped: + m = re.search(r'Hardening index\s*:\s*(\d+)', stripped) + if m: + report["hardening_index"] = int(m.group(1)) + elif report["tests_performed"] == 0 and "Tests performed" in stripped: + m = re.search(r'Tests performed\s*:\s*(\d+)', stripped) + if m: + report["tests_performed"] = int(m.group(1)) + elif not report["kernel_version"] and "Kernel version" in stripped: + m = re.search(r'Kernel version\s*:\s*(.+)', stripped) + if m: + report["kernel_version"] = m.group(1).strip() + elif not report["hostname"] and stripped.startswith("Hostname"): + m = re.search(r'Hostname\s*:\s*(.+)', stripped) + if m: + 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 if current_section and current_checks: @@ -1414,101 +1491,122 @@ def parse_lynis_report(): if report["malware_scanner"]: break - # Extract warnings/suggestions from stdout if report.dat had none - # The stdout format is: - # Warnings (5): - # ! Warning text [TEST-ID] - # Suggestions (42): - # * Suggestion text [TEST-ID] - # Also extract "Software components" section for firewall/malware status - _need_warnings = len(report["warnings"]) == 0 - _need_suggestions = len(report["suggestions"]) == 0 - 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() + # Always parse lynis-output.log for warnings, suggestions, software + # components. The report.dat is often sparse/empty on many systems. + 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_suggestions = False - in_software = False - stdout_warnings = [] - stdout_suggestions = [] + in_warnings = False + in_suggestions = False + in_software = False + stdout_warnings = [] + stdout_suggestions = [] + last_suggestion = None - for sline in stdout_lines: - sline = sline.rstrip('\n') - sstripped = sline.strip() + for sline in stdout_lines: + sline = sline.rstrip('\n') + sstripped = sline.strip() - # Detect "Warnings (N):" header - if re.match(r'^Warnings\s*\(\d+\)\s*:', sstripped): - in_warnings = True - in_suggestions = False - in_software = False - continue - # Detect "Suggestions (N):" header - if re.match(r'^Suggestions\s*\(\d+\)\s*:', sstripped): - in_suggestions = True - in_warnings = False - in_software = False - continue - # Detect "Software components:" section - if "Software components:" in sstripped: - in_software = True - in_warnings = False - in_suggestions = False - continue - # End of sections on major separators - if sstripped.startswith('==='): - in_warnings = False - in_suggestions = False - in_software = False - continue + # Detect "Warnings (N):" header + if re.match(r'^Warnings\s*\(\d+\)\s*:', sstripped): + in_warnings = True + in_suggestions = False + in_software = False + last_suggestion = None + continue + # Detect "Suggestions (N):" header + if re.match(r'^Suggestions\s*\(\d+\)\s*:', sstripped): + in_suggestions = True + in_warnings = False + in_software = False + last_suggestion = None + continue + # Detect "Software components:" section + if "Software components:" in sstripped: + in_software = True + in_warnings = False + in_suggestions = False + last_suggestion = None + continue + # End of sections on major separators + if sstripped.startswith('==='): + in_warnings = False + in_suggestions = False + in_software = False + last_suggestion = None + continue - # Parse "Software components" for firewall/malware - # Format: "- Firewall [V]" or "[X]" - if in_software: - sw_match = re.match(r'^-\s+(.+?)\s+\[([VX])\]', sstripped) - if sw_match: - sw_name = sw_match.group(1).strip().lower() - sw_status = sw_match.group(2) - if "firewall" in sw_name and sw_status == "V": - report["firewall_active"] = True - if "malware" in sw_name and sw_status == "V": - report["malware_scanner"] = True + # Parse "Software components" for firewall/malware + # Format: "- Firewall [V]" or "[X]" + if in_software: + sw_match = re.match(r'^-\s+(.+?)\s+\[([VX])\]', sstripped) + if sw_match: + sw_name = sw_match.group(1).strip().lower() + sw_status = sw_match.group(2) + if "firewall" in sw_name and sw_status == "V": + report["firewall_active"] = True + if "malware" in sw_name and sw_status == "V": + report["malware_scanner"] = True - # Parse warning lines: "! Warning text [TEST-ID]" - if in_warnings and sstripped.startswith('!'): - wm = re.match(r'^!\s+(.+?)\s*\[([A-Z0-9_-]+)\]\s*$', sstripped) - if wm: - stdout_warnings.append({ - "test_id": wm.group(2), - "severity": "Warning", - "description": wm.group(1).strip(), - "solution": "", - }) + # Parse warning lines: "! Warning text [TEST-ID]" + if in_warnings and sstripped.startswith('!'): + wm = re.match(r'^!\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped) + if wm: + stdout_warnings.append({ + "test_id": wm.group(2), + "severity": "Warning", + "description": wm.group(1).strip(), + "solution": "", + }) - # Parse suggestion lines: "* Suggestion text [TEST-ID]" - if in_suggestions and sstripped.startswith('*'): - sm = re.match(r'^\*\s+(.+?)\s*\[([A-Z0-9_-]+)\]\s*$', sstripped) + # Parse suggestion lines: "* Suggestion text [TEST-ID]" + if in_suggestions: + if sstripped.startswith('*'): + sm = re.match(r'^\*\s+(.+?)\s+\[([A-Z0-9_-]+)\]', sstripped) if sm: - stdout_suggestions.append({ + last_suggestion = { "test_id": sm.group(2), "description": sm.group(1).strip(), "solution": "", "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 - if _need_warnings and stdout_warnings: - report["warnings"] = stdout_warnings - if _need_suggestions and stdout_suggestions: - report["suggestions"] = stdout_suggestions + # Use stdout data if report.dat had none + if len(report["warnings"]) == 0 and stdout_warnings: + report["warnings"] = stdout_warnings + if len(report["suggestions"]) == 0 and stdout_suggestions: + report["suggestions"] = stdout_suggestions - except Exception: - pass + except Exception: + 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 if not report["kernel_version"]: