""" Generate an XLSX audit report with four sheets: 1. Summary – score, counts, target info 2. Passed – checks that passed, actual vs expected 3. Failed – checks that failed, actual vs expected, remediation 4. Manual Review – checks requiring human judgment, command output + criteria 5. Errors – checks that could not run Uses openpyxl (bundled with most Python environments). """ from __future__ import annotations from datetime import datetime from pathlib import Path from typing import List import openpyxl from openpyxl.styles import ( Alignment, Font, PatternFill, Border, Side ) from openpyxl.utils import get_column_letter from .models import AuditReport, CheckResult, PASS, FAIL, MANUAL, ERROR # ── colour palette ──────────────────────────────────────────────────────────── C_HEADER_BG = "1F4E79" # dark blue – header row C_HEADER_FG = "FFFFFF" # white C_PASS_BG = "E2EFDA" # light green C_FAIL_BG = "FCE4D6" # light red/orange C_MANUAL_BG = "FFF2CC" # light yellow C_ERROR_BG = "F2CEEF" # light purple C_ALT_BG = "F5F5F5" # very light grey – alternating rows C_SUMMARY_BG = "DEEAF1" # light blue – summary labels # thin border _THIN = Side(style="thin", color="BFBFBF") _BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN) # ── helpers ─────────────────────────────────────────────────────────────────── def _header_font() -> Font: return Font(name="Calibri", bold=True, color=C_HEADER_FG, size=11) def _bold(color: str = "000000") -> Font: return Font(name="Calibri", bold=True, color=color, size=11) def _normal(size: int = 11) -> Font: return Font(name="Calibri", size=size) def _fill(hex_color: str) -> PatternFill: return PatternFill("solid", fgColor=hex_color) def _wrap_align() -> Alignment: return Alignment(wrap_text=True, vertical="top") def _set_header_row(ws, headers: list[str], row: int = 1) -> None: for col, title in enumerate(headers, 1): cell = ws.cell(row=row, column=col, value=title) cell.font = _header_font() cell.fill = _fill(C_HEADER_BG) cell.alignment = Alignment(wrap_text=True, vertical="center", horizontal="center") cell.border = _BORDER def _write_row(ws, row: int, values: list, bg_color: str | None = None) -> None: for col, val in enumerate(values, 1): cell = ws.cell(row=row, column=col, value=str(val) if val is not None else "") cell.font = _normal() cell.alignment = _wrap_align() cell.border = _BORDER if bg_color: cell.fill = _fill(bg_color) elif row % 2 == 0: cell.fill = _fill(C_ALT_BG) def _auto_width(ws, min_w: int = 12, max_w: int = 60) -> None: for col_cells in ws.columns: length = max( len(str(cell.value or "")) for cell in col_cells ) col_letter = get_column_letter(col_cells[0].column) ws.column_dimensions[col_letter].width = max(min_w, min(length + 2, max_w)) def _freeze(ws, cell: str = "A2") -> None: ws.freeze_panes = cell # ── sheet builders ──────────────────────────────────────────────────────────── def _build_summary(wb: openpyxl.Workbook, report: AuditReport) -> None: ws = wb.active ws.title = "Summary" ws.sheet_view.showGridLines = False rows = [ ("CIS NGINX Benchmark v3.0.0 – Audit Report", ""), ("", ""), ("Target", report.target), ("Profile", report.profile), ("Timestamp", report.timestamp.strftime("%Y-%m-%d %H:%M:%S UTC")), ("", ""), ("Total Checks", report.total), ("✅ PASS", len(report.passed)), ("❌ FAIL", len(report.failed)), ("⚠️ MANUAL", len(report.manual)), ("🔴 ERROR", len(report.errors)), ("", ""), ] if report.total: pct_pass = round(len(report.passed) / report.total * 100, 1) pct_fail = round(len(report.failed) / report.total * 100, 1) rows.append(("Pass Rate", f"{pct_pass}%")) rows.append(("Fail Rate", f"{pct_fail}%")) for r_idx, (label, value) in enumerate(rows, 1): if r_idx == 1: cell = ws.cell(row=r_idx, column=1, value=label) cell.font = Font(name="Calibri", bold=True, size=16, color=C_HEADER_BG) cell.alignment = Alignment(vertical="center") ws.row_dimensions[r_idx].height = 30 ws.merge_cells(start_row=r_idx, start_column=1, end_row=r_idx, end_column=2) else: lbl_cell = ws.cell(row=r_idx, column=1, value=label) val_cell = ws.cell(row=r_idx, column=2, value=value) if label and label not in ("",): lbl_cell.font = _bold(C_HEADER_BG) lbl_cell.fill = _fill(C_SUMMARY_BG) val_cell.font = _normal() ws.column_dimensions["A"].width = 22 ws.column_dimensions["B"].width = 40 def _build_passed(wb: openpyxl.Workbook, results: list[CheckResult]) -> None: ws = wb.create_sheet("Passed") ws.sheet_view.showGridLines = False headers = ["Check ID", "Title", "Commands Run", "Actual Value", "Expected Value"] _set_header_row(ws, headers) for r_idx, res in enumerate(results, 2): _write_row(ws, r_idx, [ res.check_id, res.title, "\n".join(res.commands_run), res.actual_value, res.expected_value, ], bg_color=C_PASS_BG) ws.row_dimensions[r_idx].height = max( 30, min(len(res.actual_value) // 3, 120) ) _auto_width(ws) _freeze(ws) ws.column_dimensions["A"].width = 12 ws.column_dimensions["B"].width = 52 ws.column_dimensions["C"].width = 55 ws.column_dimensions["D"].width = 55 ws.column_dimensions["E"].width = 45 def _build_failed(wb: openpyxl.Workbook, results: list[CheckResult]) -> None: ws = wb.create_sheet("Failed") ws.sheet_view.showGridLines = False headers = [ "Check ID", "Title", "Commands Run", "Actual Value", "Expected Value", "Remediation Steps", "Notes" ] _set_header_row(ws, headers) for r_idx, res in enumerate(results, 2): _write_row(ws, r_idx, [ res.check_id, res.title, "\n".join(res.commands_run), res.actual_value, res.expected_value, res.remediation, res.notes, ], bg_color=C_FAIL_BG) ws.row_dimensions[r_idx].height = max( 30, min(max(len(res.actual_value), len(res.remediation)) // 3, 150) ) _auto_width(ws) _freeze(ws) ws.column_dimensions["A"].width = 12 ws.column_dimensions["B"].width = 52 ws.column_dimensions["C"].width = 55 ws.column_dimensions["D"].width = 55 ws.column_dimensions["E"].width = 45 ws.column_dimensions["F"].width = 60 ws.column_dimensions["G"].width = 40 def _build_manual(wb: openpyxl.Workbook, results: list[CheckResult]) -> None: ws = wb.create_sheet("Manual Review") ws.sheet_view.showGridLines = False headers = [ "Check ID", "Title", "Commands Run", "Command Output (Actual)", "What to Look For (Expected)", "Notes" ] _set_header_row(ws, headers) for r_idx, res in enumerate(results, 2): _write_row(ws, r_idx, [ res.check_id, res.title, "\n".join(res.commands_run), res.actual_value, res.expected_value, res.notes, ], bg_color=C_MANUAL_BG) ws.row_dimensions[r_idx].height = max( 30, min(len(res.actual_value) // 3, 150) ) _auto_width(ws) _freeze(ws) ws.column_dimensions["A"].width = 12 ws.column_dimensions["B"].width = 52 ws.column_dimensions["C"].width = 55 ws.column_dimensions["D"].width = 60 ws.column_dimensions["E"].width = 55 ws.column_dimensions["F"].width = 40 def _build_errors(wb: openpyxl.Workbook, results: list[CheckResult]) -> None: if not results: return ws = wb.create_sheet("Errors") ws.sheet_view.showGridLines = False headers = ["Check ID", "Title", "Commands Run", "Error Detail", "Notes"] _set_header_row(ws, headers) for r_idx, res in enumerate(results, 2): _write_row(ws, r_idx, [ res.check_id, res.title, "\n".join(res.commands_run), res.actual_value, res.notes, ], bg_color=C_ERROR_BG) _auto_width(ws) _freeze(ws) # ── public API ──────────────────────────────────────────────────────────────── def generate_report(report: AuditReport, output_path: str | Path) -> Path: """ Write the audit report as an XLSX file to *output_path*. Returns the resolved Path. """ wb = openpyxl.Workbook() _build_summary(wb, report) _build_passed(wb, report.passed) _build_failed(wb, report.failed) _build_manual(wb, report.manual) _build_errors(wb, report.errors) path = Path(output_path) path.parent.mkdir(parents=True, exist_ok=True) wb.save(path) return path