diff --git a/AppImage/components/security.tsx b/AppImage/components/security.tsx index 79232e19..7ac3b8d1 100644 --- a/AppImage/components/security.tsx +++ b/AppImage/components/security.tsx @@ -9,7 +9,7 @@ import { Shield, Lock, User, AlertCircle, CheckCircle, Info, LogOut, Key, Copy, Eye, EyeOff, Trash2, RefreshCw, Clock, ShieldCheck, Globe, FileKey, AlertTriangle, Flame, Bug, Search, Download, Power, PowerOff, Plus, Minus, Activity, Settings, Ban, - FileText, Printer, Play, BarChart3, TriangleAlert, + FileText, Printer, Play, BarChart3, TriangleAlert, ChevronDown, } from "lucide-react" import { getApiUrl, fetchApi } from "../lib/api-config" import { TwoFactorSetup } from "./two-factor-setup" @@ -103,20 +103,27 @@ export function Security() { // Lynis audit state interface LynisWarning { test_id: string; severity: string; description: string; solution: string } interface LynisSuggestion { test_id: string; description: string; solution: string; details: string } + interface LynisCheck { + name: string; status: string; detail?: string + } + interface LynisSection { + name: string; checks: LynisCheck[] + } interface LynisReport { datetime_start: string; datetime_end: string; lynis_version: string - os_name: string; os_version: string; hostname: string + os_name: string; os_version: string; os_fullname: string; hostname: string hardening_index: number | null; tests_performed: number warnings: LynisWarning[]; suggestions: LynisSuggestion[] categories: Record installed_packages: number; kernel_version: string firewall_active: boolean; malware_scanner: boolean + sections: LynisSection[] } const [lynisAuditRunning, setLynisAuditRunning] = useState(false) const [lynisReport, setLynisReport] = useState(null) const [lynisReportLoading, setLynisReportLoading] = useState(false) const [lynisShowReport, setLynisShowReport] = useState(false) - const [lynisActiveTab, setLynisActiveTab] = useState<"overview" | "warnings" | "suggestions">("overview") + const [lynisActiveTab, setLynisActiveTab] = useState<"overview" | "warnings" | "suggestions" | "checks">("overview") // Fail2Ban detailed state interface BannedIp { @@ -984,7 +991,7 @@ export function Security() {

System Hardening Assessment

This automated security audit was performed on host ${report.hostname || "Unknown"} - running ${report.os_name} ${report.os_version}. + running ${report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "Unknown OS"}. A total of ${report.tests_performed} tests were executed, resulting in ${report.warnings.length} warning(s) and ${report.suggestions.length} suggestion(s) for improvement. @@ -1003,7 +1010,7 @@ export function Security() {

Operating System
-
${report.os_name} ${report.os_version}
+
${report.os_fullname || `${report.os_name} ${report.os_version}`.trim() || "N/A"}
Kernel
@@ -1014,12 +1021,12 @@ export function Security() {
${report.lynis_version || "N/A"}
-
Scan Started
-
${report.datetime_start || "N/A"}
+
Report Date
+
${report.datetime_start ? report.datetime_start.replace("T", " ").substring(0, 16) : "N/A"}
-
Scan Ended
-
${report.datetime_end || "N/A"}
+
Tests Performed
+
${report.tests_performed}
@@ -1029,15 +1036,15 @@ export function Security() {
3. Security Posture Overview
-
${report.hardening_index ?? "N/A"}
-
Hardening Score
+
${report.hardening_index ?? "N/A"}/100
+
Hardening Score (${scoreLabel})
-
${report.warnings.length}
+
${report.warnings.length}
Warnings
-
${report.suggestions.length}
+
${report.suggestions.length}
Suggestions
@@ -1045,7 +1052,7 @@ export function Security() {
Tests Performed
-
+
Firewall
${report.firewall_active ? "Active" : "Inactive"}
@@ -1055,13 +1062,9 @@ export function Security() {
${report.malware_scanner ? "Installed" : "Not Found"}
-
Packages
+
Installed Packages
${report.installed_packages || "N/A"}
-
-
Score Rating
-
${scoreLabel}
-
@@ -1101,6 +1104,43 @@ export function Security() { `).join("")} + +${(report.sections && report.sections.length > 0) ? ` +
+
6. Detailed Security Checks (${report.sections.length} categories)
+

Complete list of all security checks performed during the audit, organized by category.

+ ${report.sections.map((section, sIdx) => ` +
+
+ ${sIdx + 1} + ${section.name} + ${section.checks.length} checks +
+ + + + + + + + + ${section.checks.map(check => { + const st = check.status.toUpperCase() + const isWarn = ["WARNING", "UNSAFE", "WEAK", "DIFFERENT", "DISABLED"].includes(st) + const isSugg = ["SUGGESTION", "PARTIALLY HARDENED", "MEDIUM", "NON DEFAULT"].includes(st) + const isOk = ["OK", "FOUND", "DONE", "ENABLED", "ACTIVE", "YES", "HARDENED", "PROTECTED"].includes(st) + const color = isWarn ? "#dc2626" : isSugg ? "#ca8a04" : isOk ? "#16a34a" : "#64748b" + const bg = isWarn ? "#fef2f2" : isSugg ? "#fefce8" : "transparent" + return ` + + + ` + }).join("")} + +
CheckStatus
${check.name}${check.detail ? ` (${check.detail})` : ""}${check.status}
+
`).join("")} +
` : ""} + {/* Hardening bar */} - {lynisInfo.hardening_index !== null && ( -
-
- Security Hardening Score - = 70 ? "text-green-500" : - lynisInfo.hardening_index >= 50 ? "text-yellow-500" : - "text-red-500" - }`}> - {lynisInfo.hardening_index}/100 - + {(() => { + const score = lynisReport?.hardening_index ?? lynisInfo.hardening_index + if (score === null || score === undefined) return null + return ( +
+
+ Security Hardening Score + = 70 ? "text-green-500" : score >= 50 ? "text-yellow-500" : "text-red-500" + }`}> + {score}/100 + +
+
+
= 70 ? "bg-green-500" : score >= 50 ? "bg-yellow-500" : "bg-red-500" + }`} + style={{ width: `${score}%` }} + /> +
+
+ Critical (0-49) + Moderate (50-69) + Good (70-100) +
-
-
= 70 ? "bg-green-500" : - lynisInfo.hardening_index >= 50 ? "bg-yellow-500" : - "bg-red-500" - }`} - style={{ width: `${lynisInfo.hardening_index}%` }} - /> -
-
- Critical (0-49) - Moderate (50-69) - Good (70-100) -
-
- )} - - {/* Action buttons */} -
- - {lynisReport && ( - - )} -
+ ) + })()} {/* Running indicator */} {lynisAuditRunning && ( @@ -2997,192 +3008,296 @@ export function Security() {
)} - {/* Report viewer */} - {lynisShowReport && lynisReport && ( -
- {/* Report header */} -
-
- - Audit Report - {lynisReport.datetime_start && ( - - - {lynisReport.datetime_start.replace("T", " ").substring(0, 16)} - - )} -
- -
- - {/* System info strip */} -
-
-

Hostname

-

{lynisReport.hostname || "N/A"}

-
-
-

OS

-

{lynisReport.os_name} {lynisReport.os_version}

-
-
-

Kernel

-

{lynisReport.kernel_version || "N/A"}

-
-
-

Tests

-

{lynisReport.tests_performed}

-
-
- - {/* Report tabs */} -
- {(["overview", "warnings", "suggestions"] as const).map((tab) => ( - - ))} -
- - {/* Overview tab */} - {lynisActiveTab === "overview" && ( -
-
-
-

Packages

-

{lynisReport.installed_packages || "N/A"}

-
-
-

Firewall

-

- {lynisReport.firewall_active ? "Active" : "Inactive"} +

+ +
+

+ Security Audit - {lynisReport.datetime_start + ? lynisReport.datetime_start.replace("T", " ").substring(0, 16) + : lynisInfo.last_scan?.replace("T", " ").substring(0, 16) || "Unknown date"}

-
-
-

Malware Scanner

-

- {lynisReport.malware_scanner ? "Installed" : "Not Found"} +

+ {lynisReport.hostname || "System"} - {lynisReport.tests_performed} tests - Score: {lynisReport.hardening_index ?? "N/A"}/100 - {lynisReport.warnings.length} warnings - {lynisReport.suggestions.length} suggestions

+
+ + + +
+ - {/* Security checklist */} -
-

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) => ( -
-
- {item.label} - - {item.ok ? "PASS" : "FAIL"} - + {/* Expanded report details */} + {lynisShowReport && ( +
+ {/* System info strip */} +
+
+

Hostname

+

{lynisReport.hostname || "N/A"}

- ))} +
+

OS

+

{lynisReport.os_fullname || `${lynisReport.os_name} ${lynisReport.os_version}`.trim() || "N/A"}

+
+
+

Kernel

+

{lynisReport.kernel_version || "N/A"}

+
+
+

Tests

+

{lynisReport.tests_performed}

+
+
+ + {/* Report tabs */} +
+ {(["overview", "checks", "warnings", "suggestions"] as const).map((tab) => ( + + ))} +
+ + {/* Overview tab */} + {lynisActiveTab === "overview" && ( +
+
+
+

Packages

+

{lynisReport.installed_packages || "N/A"}

+
+
+

Firewall

+

+ {lynisReport.firewall_active ? "Active" : "Inactive"} +

+
+
+

Malware Scanner

+

+ {lynisReport.malware_scanner ? "Installed" : "Not Found"} +

+
+
+ + {/* Security checklist */} +
+

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) => ( +
+
+ {item.label} + + {item.ok ? "PASS" : "FAIL"} + +
+ ))} +
+
+ )} + + {/* Checks tab */} + {lynisActiveTab === "checks" && ( +
+ {(!lynisReport.sections || lynisReport.sections.length === 0) ? ( +
+ No check details available. Run an audit to generate detailed results. +
+ ) : ( +
+ {lynisReport.sections.map((section, sIdx) => ( +
+
+ {sIdx + 1} + {section.name} + {section.checks.length} checks +
+
+ {section.checks.map((check, cIdx) => { + const st = check.status.toUpperCase() + const isOk = ["OK", "FOUND", "DONE", "ENABLED", "ACTIVE", "YES", "HARDENED", "PROTECTED", "NONE", "NOT FOUND", "NOT RUNNING", "NOT ACTIVE", "NOT ENABLED", "DEFAULT", "NO"].includes(st) + const isWarn = ["WARNING", "UNSAFE", "WEAK", "DIFFERENT", "DISABLED"].includes(st) + const isSugg = ["SUGGESTION", "PARTIALLY HARDENED", "MEDIUM", "NON DEFAULT"].includes(st) + const dotColor = isWarn ? "bg-red-500" : isSugg ? "bg-yellow-500" : isOk ? "bg-green-500" : "bg-muted-foreground" + const textColor = isWarn ? "text-red-500" : isSugg ? "text-yellow-500" : isOk ? "text-green-500" : "text-muted-foreground" + return ( +
+
+ {check.name} + {check.detail && {check.detail}} + {check.status} +
+ ) + })} +
+
+ ))} +
+ )} +
+ )} + + {/* Warnings tab */} + {lynisActiveTab === "warnings" && ( +
+ {lynisReport.warnings.length === 0 ? ( +
+ No warnings found. Your system is well configured. +
+ ) : ( +
+ {lynisReport.warnings.map((w, idx) => ( +
+
+
+
+
+ {w.test_id} + {w.severity && ( + {w.severity} + )} +
+

{w.description}

+ {w.solution && ( +

+ Solution: {w.solution} +

+ )} +
+
+
+ ))} +
+ )} +
+ )} + + {/* Suggestions tab */} + {lynisActiveTab === "suggestions" && ( +
+ {lynisReport.suggestions.length === 0 ? ( +
+ No suggestions. System is fully hardened. +
+ ) : ( +
+ {lynisReport.suggestions.map((s, idx) => ( +
+
+
+
+
+ {s.test_id} +
+

{s.description}

+ {s.solution && ( +

+ Solution: {s.solution} +

+ )} + {s.details && ( +

{s.details}

+ )} +
+
+
+ ))} +
+ )} +
+ )}
-
- )} - - {/* Warnings tab */} - {lynisActiveTab === "warnings" && ( -
- {lynisReport.warnings.length === 0 ? ( -
- No warnings found. Your system is well configured. -
- ) : ( -
- {lynisReport.warnings.map((w, idx) => ( -
-
-
-
-
- {w.test_id} - {w.severity && ( - {w.severity} - )} -
-

{w.description}

- {w.solution && ( -

- Solution: {w.solution} -

- )} -
-
-
- ))} -
- )} -
- )} - - {/* Suggestions tab */} - {lynisActiveTab === "suggestions" && ( -
- {lynisReport.suggestions.length === 0 ? ( -
- No suggestions. System is fully hardened. -
- ) : ( -
- {lynisReport.suggestions.map((s, idx) => ( -
-
-
-
-
- {s.test_id} -
-

{s.description}

- {s.solution && ( -

- Solution: {s.solution} -

- )} - {s.details && ( -

{s.details}

- )} -
-
-
- ))} -
- )} -
- )} + )} +
)} + + {/* Run audit button - at the bottom */} +
)} diff --git a/AppImage/scripts/flask_security_routes.py b/AppImage/scripts/flask_security_routes.py index 0dcff8f1..426145da 100644 --- a/AppImage/scripts/flask_security_routes.py +++ b/AppImage/scripts/flask_security_routes.py @@ -255,6 +255,26 @@ def lynis_report(): return jsonify({"success": False, "message": str(e)}), 500 +@security_bp.route('/api/security/lynis/report', methods=['DELETE']) +def lynis_report_delete(): + """Delete Lynis audit report files""" + if not security_manager: + return jsonify({"success": False, "message": "Security manager not available"}), 500 + try: + import os + deleted = [] + for f in ["/var/log/lynis-report.dat", "/var/log/lynis.log", "/var/log/lynis-output.log"]: + if os.path.isfile(f): + os.remove(f) + deleted.append(f) + if deleted: + return jsonify({"success": True, "message": f"Deleted: {', '.join(deleted)}"}) + else: + return jsonify({"success": False, "message": "No report files found to delete"}) + except Exception as e: + return jsonify({"success": False, "message": str(e)}), 500 + + # ------------------------------------------------------------------- # Security Tools Detection # ------------------------------------------------------------------- diff --git a/AppImage/scripts/security_manager.py b/AppImage/scripts/security_manager.py index 1c13d29b..eb3e8d4d 100644 --- a/AppImage/scripts/security_manager.py +++ b/AppImage/scripts/security_manager.py @@ -1042,21 +1042,27 @@ def _detect_lynis(): if rc == 0: info["version"] = out.strip() - # Check for last scan report - report_file = "/var/log/lynis-report.dat" - if os.path.isfile(report_file): - try: - with open(report_file, 'r') as f: - for line in f: - if line.startswith("report_datetime_start="): - info["last_scan"] = line.split("=", 1)[1].strip() - elif line.startswith("hardening_index="): - try: - info["hardening_index"] = int(line.split("=", 1)[1].strip()) - except ValueError: - pass - except Exception: - pass + # Check for last scan report - use full parser for accurate data + report = parse_lynis_report() + if report: + info["last_scan"] = report.get("datetime_start", None) + info["hardening_index"] = report.get("hardening_index", None) + else: + # Fallback: quick read of report.dat + report_file = "/var/log/lynis-report.dat" + if os.path.isfile(report_file): + try: + with open(report_file, 'r') as f: + for line in f: + if line.startswith("report_datetime_start="): + info["last_scan"] = line.split("=", 1)[1].strip() + elif line.startswith("hardening_index="): + try: + info["hardening_index"] = int(line.split("=", 1)[1].strip()) + except ValueError: + pass + except Exception: + pass return info @@ -1098,6 +1104,13 @@ def run_lynis_audit(): [lynis_cmd, "audit", "system", "--no-colors", "--quick"], timeout=600 ) + # Save stdout output for section parsing + if out: + try: + with open("/var/log/lynis-output.log", "w") as fout: + fout.write(out) + except Exception: + pass if rc == 0: _lynis_audit_progress = "completed" else: @@ -1123,6 +1136,7 @@ def get_lynis_audit_status(): def parse_lynis_report(): """ Parse /var/log/lynis-report.dat into structured report data. + Also enriches with data from lynis.log when report.dat is sparse. Returns a dict with all audit findings. """ report_file = "/var/log/lynis-report.dat" @@ -1135,6 +1149,7 @@ def parse_lynis_report(): "lynis_version": "", "os_name": "", "os_version": "", + "os_fullname": "", "hostname": "", "hardening_index": None, "tests_performed": 0, @@ -1147,11 +1162,16 @@ def parse_lynis_report(): "malware_scanner": False, } + # Collect all raw key-value pairs first for flexible matching + raw_data = {} + 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("#"): + if not line or line.startswith("#") or line.startswith("["): continue if "=" not in line: @@ -1161,73 +1181,212 @@ def parse_lynis_report(): key = key.strip() value = value.strip() - if key == "report_datetime_start": - report["datetime_start"] = value - elif key == "report_datetime_end": - report["datetime_end"] = value - elif key == "lynis_version": - report["lynis_version"] = value - elif key == "os_name": - report["os_name"] = value - elif key == "os_version": - report["os_version"] = value - elif key == "hostname": - report["hostname"] = value - elif key == "hardening_index": - try: - report["hardening_index"] = int(value) - except ValueError: - pass - elif key == "tests_performed": - try: - report["tests_performed"] = int(value) - except ValueError: - pass - elif key == "installed_packages": - try: - report["installed_packages"] = int(value) - except ValueError: - pass - elif key == "linux_kernel_version": - report["kernel_version"] = value - elif key == "firewall_active": - report["firewall_active"] = value == "1" - elif key == "malware_scanner_installed": - report["malware_scanner"] = value == "1" - - # Warnings: warning[]=TEST_ID|severity|description|solution - elif key == "warning[]": - parts = value.split("|") - if len(parts) >= 3: - report["warnings"].append({ - "test_id": parts[0].strip(), - "severity": parts[1].strip() if len(parts) > 1 else "", - "description": parts[2].strip() if len(parts) > 2 else "", - "solution": parts[3].strip() if len(parts) > 3 else "", - }) - - # Suggestions: suggestion[]=TEST_ID|description|solution|details + if key == "warning[]": + warnings_raw.append(value) elif key == "suggestion[]": - parts = value.split("|") - if len(parts) >= 2: - report["suggestions"].append({ - "test_id": parts[0].strip(), - "description": parts[1].strip() if len(parts) > 1 else "", - "solution": parts[2].strip() if len(parts) > 2 else "", - "details": parts[3].strip() if len(parts) > 3 else "", - }) - - # Category results - elif key.endswith("_score"): - cat_name = key.replace("_score", "") - try: - if cat_name not in report["categories"]: - report["categories"][cat_name] = {} - report["categories"][cat_name]["score"] = int(value) - except ValueError: - pass - + suggestions_raw.append(value) + else: + # Last value wins (some keys appear multiple times) + raw_data[key] = value except Exception: return None + # Map known fields (Lynis uses varied naming across versions) + report["datetime_start"] = raw_data.get("report_datetime_start", "") + report["datetime_end"] = raw_data.get("report_datetime_end", "") + report["lynis_version"] = raw_data.get("lynis_version", "") + report["hostname"] = raw_data.get("hostname", "") + + # OS name - try multiple fields + report["os_name"] = (raw_data.get("os_name", "") or + raw_data.get("os", "") or + raw_data.get("os_fullname", "")) + report["os_version"] = (raw_data.get("os_version", "") or + raw_data.get("os_version_id", "")) + report["os_fullname"] = raw_data.get("os_fullname", "") + + # Kernel - try multiple field names + report["kernel_version"] = (raw_data.get("os_kernel_version_full", "") or + raw_data.get("os_kernel_version", "") or + raw_data.get("linux_kernel_version", "") or + raw_data.get("linux_version", "") or + raw_data.get("os_kernelversion_full", "") or + raw_data.get("os_kernelversion", "")) + + # Hardening index + for k in ["hardening_index", "hpindex", "hp_index"]: + if k in raw_data: + try: + report["hardening_index"] = int(raw_data[k]) + break + except ValueError: + pass + + # Tests performed + for k in ["tests_performed", "ctests_performed", "total_tests"]: + if k in raw_data: + try: + val = int(raw_data[k]) + if val > report["tests_performed"]: + report["tests_performed"] = val + except ValueError: + pass + + # Installed packages + for k in ["installed_packages", "installed_packages_array"]: + if k in raw_data: + try: + report["installed_packages"] = int(raw_data[k]) + except ValueError: + # Might be a string like "package1,package2" - count them + pkgs = raw_data[k] + if pkgs: + report["installed_packages"] = len(pkgs.split(",")) + + # Firewall + for k in ["firewall_active", "firewall_installed"]: + if k in raw_data and raw_data[k] in ("1", "true", "yes"): + report["firewall_active"] = True + break + + # Malware scanner + for k in ["malware_scanner_installed", "malware_scanner"]: + if k in raw_data and raw_data[k] in ("1", "true", "yes"): + report["malware_scanner"] = True + break + + # Parse warnings + for w in warnings_raw: + parts = w.split("|") + if len(parts) >= 2: + report["warnings"].append({ + "test_id": parts[0].strip() if len(parts) > 0 else "", + "severity": parts[1].strip() if len(parts) > 1 else "", + "description": parts[2].strip() if len(parts) > 2 else parts[1].strip(), + "solution": parts[3].strip() if len(parts) > 3 else "", + }) + + # Parse suggestions + for s in suggestions_raw: + parts = s.split("|") + if len(parts) >= 2: + report["suggestions"].append({ + "test_id": parts[0].strip() if len(parts) > 0 else "", + "description": parts[1].strip() if len(parts) > 1 else "", + "solution": parts[2].strip() if len(parts) > 2 else "", + "details": parts[3].strip() if len(parts) > 3 else "", + }) + + # Parse lynis-output.log (stdout) for section checks, fallback to lynis.log + report["sections"] = [] + # Prefer the stdout output which has clean formatted sections + output_file = "/var/log/lynis-output.log" + log_file = output_file if os.path.isfile(output_file) else "/var/log/lynis.log" + if os.path.isfile(log_file): + try: + import re + with open(log_file, 'r') as f: + log_lines = f.readlines() + + current_section = None + current_checks = [] + + for line in log_lines: + line = line.rstrip('\n') + stripped = line.strip() + + # Detect section headers: "[+] Boot and services" + section_match = re.match(r'^\[\+\]\s+(.+)', stripped) + if section_match: + # Save previous section + if current_section and current_checks: + report["sections"].append({ + "name": current_section, + "checks": current_checks, + }) + current_section = section_match.group(1).strip() + current_checks = [] + continue + + # Skip separator lines, empty, banner lines + if stripped.startswith('---') or not stripped: + continue + if stripped.startswith('===') or stripped.startswith('#'): + current_section = None # Stop parsing after results summary + continue + + # Detect any line with [ STATUS ] pattern (covers -, File:, Directory:, etc.) + check_match = re.match( + r'^[\s]*[-*]?\s*(.+?)\s{2,}\[\s*(.+?)\s*\]\s*$', stripped + ) + if check_match and current_section: + check_name = check_match.group(1).strip() + check_status = check_match.group(2).strip() + # Skip noise lines + if check_name and not check_name.startswith('..') and len(check_name) > 2: + current_checks.append({ + "name": check_name, + "status": check_status, + }) + 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() + 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 + + # Save last section + if current_section and current_checks: + report["sections"].append({ + "name": current_section, + "checks": current_checks, + }) + + # Filter out sections with no meaningful checks + report["sections"] = [ + s for s in report["sections"] + if len(s["checks"]) > 0 + ] + + except Exception: + report["sections"] = [] + + # Fallback: get kernel from uname if still empty + if not report["kernel_version"]: + try: + rc, out, _ = _run_cmd(["uname", "-r"]) + if rc == 0 and out.strip(): + report["kernel_version"] = out.strip() + except Exception: + pass + + # Fallback: get hostname from system + if not report["hostname"]: + try: + import socket + report["hostname"] = socket.gethostname() + except Exception: + pass + return report