277 lines
9.5 KiB
Python
277 lines
9.5 KiB
Python
"""
|
||
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
|