feat(compliance): improve pdf report

This commit is contained in:
pedrooot
2025-06-11 11:01:32 +02:00
parent f8cd91a0ce
commit 7aacb0e1f1
+336 -54
View File
@@ -1,7 +1,11 @@
import io
import matplotlib.pyplot as plt
import requests
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import inch
from reportlab.platypus import (
Image,
@@ -15,7 +19,12 @@ from reportlab.platypus import (
def generate_compliance_report(
scan_id: str, compliance_id: str, output_path: str, email: str, password: str
scan_id: str,
compliance_id: str,
output_path: str,
email: str,
password: str,
only_failed: bool = False,
):
"""
Generate a PDF compliance report based on Prowler endpoints.
@@ -26,15 +35,72 @@ def generate_compliance_report(
- output_path: Output PDF file path (e.g., "compliance_report.pdf").
- email: Email for the API authentication.
- password: Password for the API.
- only_failed: If True, only requirements with status "FAIL" will be included in the list of requirements.
"""
styles = getSampleStyleSheet()
title_style = styles["Title"]
h1 = styles["Heading1"]
h2 = styles["Heading2"]
h3 = styles["Heading3"]
normal = styles["Normal"]
# Call to this endpoint to get the credentials
title_style = ParagraphStyle(
"CustomTitle",
parent=styles["Title"],
fontSize=24,
textColor=colors.Color(0.1, 0.2, 0.4),
spaceAfter=20,
fontName="Helvetica-Bold",
alignment=TA_CENTER,
)
h1 = ParagraphStyle(
"CustomH1",
parent=styles["Heading1"],
fontSize=18,
textColor=colors.Color(0.2, 0.4, 0.6),
spaceBefore=20,
spaceAfter=12,
fontName="Helvetica-Bold",
leftIndent=0,
borderWidth=2,
borderColor=colors.Color(0.2, 0.4, 0.6),
borderPadding=8,
backColor=colors.Color(0.95, 0.97, 1.0),
)
h2 = ParagraphStyle(
"CustomH2",
parent=styles["Heading2"],
fontSize=14,
textColor=colors.Color(0.3, 0.5, 0.7),
spaceBefore=15,
spaceAfter=8,
fontName="Helvetica-Bold",
leftIndent=10,
borderWidth=1,
borderColor=colors.Color(0.7, 0.8, 0.9),
borderPadding=5,
backColor=colors.Color(0.98, 0.99, 1.0),
)
h3 = ParagraphStyle(
"CustomH3",
parent=styles["Heading3"],
fontSize=12,
textColor=colors.Color(0.4, 0.6, 0.8),
spaceBefore=10,
spaceAfter=6,
fontName="Helvetica-Bold",
leftIndent=20,
)
normal = ParagraphStyle(
"CustomNormal",
parent=styles["Normal"],
fontSize=10,
textColor=colors.Color(0.2, 0.2, 0.2),
spaceBefore=4,
spaceAfter=4,
leftIndent=30,
fontName="Helvetica",
)
url_credentials = "http://localhost:8080/api/v1/tokens"
payload = {
"data": {
@@ -73,28 +139,24 @@ def generate_compliance_report(
def create_risk_component(risk_level, weight, score=0):
"""Create a visual risk component similar to the UI design"""
# Define colors based on risk level
if risk_level >= 4:
risk_color = colors.Color(0.8, 0.2, 0.2) # Red
risk_color = colors.Color(0.8, 0.2, 0.2)
elif risk_level >= 3:
risk_color = colors.Color(0.9, 0.6, 0.2) # Orange
risk_color = colors.Color(0.9, 0.6, 0.2)
elif risk_level >= 2:
risk_color = colors.Color(0.9, 0.9, 0.2) # Yellow
risk_color = colors.Color(0.9, 0.9, 0.2)
else:
risk_color = colors.Color(0.2, 0.8, 0.2) # Green
risk_color = colors.Color(0.2, 0.8, 0.2)
# Weight color (green for high values)
if weight >= 100:
weight_color = colors.Color(0.2, 0.8, 0.2) # Green
elif weight >= 50:
weight_color = colors.Color(0.9, 0.9, 0.2) # Yellow
if weight <= 50:
weight_color = colors.Color(0.2, 0.8, 0.2)
elif weight <= 100:
weight_color = colors.Color(0.9, 0.9, 0.2)
else:
weight_color = colors.Color(0.8, 0.2, 0.2) # Red
weight_color = colors.Color(0.8, 0.2, 0.2)
# Score color (gray for 0)
score_color = colors.Color(0.4, 0.4, 0.4) # Gray
score_color = colors.Color(0.4, 0.4, 0.4)
# Create table data
data = [
[
"Risk Level:",
@@ -106,7 +168,6 @@ def generate_compliance_report(
]
]
# Create table
table = Table(
data,
colWidths=[
@@ -119,26 +180,21 @@ def generate_compliance_report(
],
)
# Apply styling
table.setStyle(
TableStyle(
[
# Risk Level styling
("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)),
("BACKGROUND", (1, 0), (1, 0), risk_color),
("TEXTCOLOR", (1, 0), (1, 0), colors.white),
("FONTNAME", (1, 0), (1, 0), "Helvetica-Bold"),
# Weight styling
("BACKGROUND", (2, 0), (2, 0), colors.Color(0.9, 0.9, 0.9)),
("BACKGROUND", (3, 0), (3, 0), weight_color),
("TEXTCOLOR", (3, 0), (3, 0), colors.white),
("FONTNAME", (3, 0), (3, 0), "Helvetica-Bold"),
# Score styling
("BACKGROUND", (4, 0), (4, 0), colors.Color(0.9, 0.9, 0.9)),
("BACKGROUND", (5, 0), (5, 0), score_color),
("TEXTCOLOR", (5, 0), (5, 0), colors.white),
("FONTNAME", (5, 0), (5, 0), "Helvetica-Bold"),
# General styling
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 0), (-1, -1), 10),
@@ -155,32 +211,25 @@ def generate_compliance_report(
def create_status_component(status):
"""Create a visual status component with colors"""
# Define colors based on status
if status.upper() == "PASS":
status_color = colors.Color(0.2, 0.8, 0.2) # Green
status_color = colors.Color(0.2, 0.8, 0.2)
elif status.upper() == "FAIL":
status_color = colors.Color(0.8, 0.2, 0.2) # Red
status_color = colors.Color(0.8, 0.2, 0.2)
else:
status_color = colors.Color(0.4, 0.4, 0.4) # Gray for unknown status
status_color = colors.Color(0.4, 0.4, 0.4)
# Create table data
data = [["State:", status.upper()]]
# Create table
table = Table(data, colWidths=[0.6 * inch, 0.8 * inch])
# Apply styling
table.setStyle(
TableStyle(
[
# Label styling
("BACKGROUND", (0, 0), (0, 0), colors.Color(0.9, 0.9, 0.9)),
("FONTNAME", (0, 0), (0, 0), "Helvetica"),
# Status styling
("BACKGROUND", (1, 0), (1, 0), status_color),
("TEXTCOLOR", (1, 0), (1, 0), colors.white),
("FONTNAME", (1, 0), (1, 0), "Helvetica-Bold"),
# General styling
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 0), (-1, -1), 12),
@@ -195,6 +244,99 @@ def generate_compliance_report(
return table
def create_section_score_chart(resp_reqs, attrs_map):
"""Create a bar chart showing compliance score by section"""
sections_data = {}
for req in resp_reqs:
req_id = req["id"]
attr = attrs_map.get(req_id, {})
status = req["attributes"]["status"]
metadata = attr.get("attributes", {}).get("metadata", [])
if metadata:
m = metadata[0]
section = m.get("Section", "Unknown")
risk_level = m.get("LevelOfRisk", 0)
weight = m.get("Weight", 0)
if section not in sections_data:
sections_data[section] = {"total_score": 0, "max_possible_score": 0}
max_score = risk_level * weight
sections_data[section]["max_possible_score"] += max_score
if status == "PASS":
sections_data[section]["total_score"] += max_score
section_names = []
compliance_percentages = []
for section, data in sections_data.items():
if data["max_possible_score"] > 0:
compliance_percentage = (
data["total_score"] / data["max_possible_score"]
) * 100
else:
compliance_percentage = 0
section_names.append(section)
compliance_percentages.append(compliance_percentage)
sorted_data = sorted(
zip(section_names, compliance_percentages), key=lambda x: x[1], reverse=True
)
section_names, compliance_percentages = (
zip(*sorted_data) if sorted_data else ([], [])
)
fig, ax = plt.subplots(figsize=(12, 8))
colors_list = []
for percentage in compliance_percentages:
if percentage >= 80:
color = "#4CAF50"
elif percentage >= 60:
color = "#8BC34A"
elif percentage >= 40:
color = "#FFEB3B"
elif percentage >= 20:
color = "#FF9800"
else:
color = "#F44336"
colors_list.append(color)
bars = ax.bar(section_names, compliance_percentages, color=colors_list)
ax.set_ylabel("Compliance Score (%)", fontsize=12)
ax.set_xlabel("Section", fontsize=12)
ax.set_title("COMPLIANCE SCORE BY SECTIONS", fontsize=14, fontweight="bold")
ax.set_ylim(0, 100)
for bar, percentage in zip(bars, compliance_percentages):
height = bar.get_height()
ax.text(
bar.get_x() + bar.get_width() / 2.0,
height + 1,
f"{percentage:.1f}%",
ha="center",
va="bottom",
fontweight="bold",
)
plt.xticks(rotation=45, ha="right")
ax.grid(True, alpha=0.3, axis="y")
plt.tight_layout()
buffer = io.BytesIO()
plt.savefig(buffer, format="png", dpi=300, bbox_inches="tight")
buffer.seek(0)
plt.close()
return buffer
def get_finding_info(check_id: str):
url_find = f"http://localhost:8080/api/v1/findings?filter[check_id]={check_id}&filter[scan_id]={scan_id}"
value = (
@@ -204,32 +346,65 @@ def generate_compliance_report(
)
return value
doc = SimpleDocTemplate(output_path, pagesize=letter)
doc = SimpleDocTemplate(
output_path,
pagesize=letter,
title=f"Compliance Report - {compliance_name}",
author="Prowler",
subject=f"Compliance Report for {compliance_name}",
creator="Prowler Compliance Generator",
keywords=f"compliance,{compliance_name},security,framework,prowler",
)
elements = []
try:
logo = Image(
"util/compliance_report/assets/img/prowler_logo.png",
width=5 * inch,
height=1 * inch,
height=0.8 * inch,
)
elements.append(logo)
except Exception:
pass
elements.append(Spacer(1, 0.5 * inch))
elements.append(Spacer(1, 0.3 * inch))
elements.append(Paragraph("Compliance Report - Prowler", title_style))
elements.append(Spacer(1, 0.2 * inch))
elements.append(Paragraph(f"Compliance ID: <b>{compliance_id}</b>", normal))
elements.append(Paragraph(f"Scan ID: <b>{scan_id}</b>", normal))
elements.append(Paragraph(f"Compliance Name: <b>{compliance_name}</b>", normal))
elements.append(
Paragraph(f"Compliance Version: <b>{compliance_version}</b>", normal)
elements.append(Spacer(1, 0.3 * inch))
info_data = [
["Compliance Framework:", compliance_name],
["Compliance ID:", compliance_id],
["Version:", compliance_version],
["Scan ID:", scan_id],
]
info_table = Table(info_data, colWidths=[2 * inch, 4 * inch])
info_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (0, -1), colors.Color(0.2, 0.4, 0.6)),
("TEXTCOLOR", (0, 0), (0, -1), colors.white),
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
("BACKGROUND", (1, 0), (1, -1), colors.Color(0.95, 0.97, 1.0)),
("TEXTCOLOR", (1, 0), (1, -1), colors.Color(0.2, 0.2, 0.2)),
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 0), (-1, -1), 11),
("GRID", (0, 0), (-1, -1), 1, colors.Color(0.7, 0.8, 0.9)),
("LEFTPADDING", (0, 0), (-1, -1), 10),
("RIGHTPADDING", (0, 0), (-1, -1), 10),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
]
)
)
elements.append(info_table)
elements.append(Spacer(1, 0.2 * inch))
elements.append(PageBreak())
elements.append(Paragraph("Requirements Index", h1))
# Organize requirements by section and subsection
sections = {}
for req in resp_attrs:
meta = req["attributes"]["attributes"]["metadata"][0]
@@ -245,7 +420,6 @@ def generate_compliance_report(
sections[section][subsection].append({"id": req_id, "title": title})
# Generate hierarchical index
section_num = 1
for section_name, subsections in sections.items():
elements.append(Paragraph(f"{section_num}. {section_name}", h2))
@@ -266,7 +440,101 @@ def generate_compliance_report(
elements.append(PageBreak())
elements.append(Paragraph("Compliance Score by Sections", h1))
elements.append(Spacer(1, 0.2 * inch))
chart_buffer = create_section_score_chart(resp_reqs, attrs_map)
chart_image = Image(chart_buffer, width=7 * inch, height=5.5 * inch)
elements.append(chart_image)
total_score = 0
max_possible_score = 0
for req in resp_reqs:
req_id = req["id"]
attr = attrs_map.get(req_id, {})
status = req["attributes"]["status"]
metadata = attr.get("attributes", {}).get("metadata", [])
if metadata:
m = metadata[0]
risk_level = m.get("LevelOfRisk", 0)
weight = m.get("Weight", 0)
max_score = risk_level * weight
max_possible_score += max_score
if status == "PASS":
total_score += max_score
overall_compliance = (
(total_score / max_possible_score * 100) if max_possible_score > 0 else 0
)
elements.append(Spacer(1, 0.3 * inch))
summary_data = [
["Total Score:", f"{total_score:,}"],
["Max Possible Score:", f"{max_possible_score:,}"],
["Overall Compliance:", f"{overall_compliance:.2f}%"],
]
if overall_compliance >= 80:
compliance_color = colors.Color(0.2, 0.8, 0.2)
elif overall_compliance >= 60:
compliance_color = colors.Color(0.8, 0.8, 0.2)
else:
compliance_color = colors.Color(0.8, 0.2, 0.2)
summary_table = Table(summary_data, colWidths=[2.5 * inch, 2 * inch])
summary_table.setStyle(
TableStyle(
[
("BACKGROUND", (0, 0), (0, 1), colors.Color(0.3, 0.5, 0.7)),
("TEXTCOLOR", (0, 0), (0, 1), colors.white),
("FONTNAME", (0, 0), (0, 1), "Helvetica-Bold"),
("BACKGROUND", (0, 2), (0, 2), colors.Color(0.1, 0.3, 0.5)),
("TEXTCOLOR", (0, 2), (0, 2), colors.white),
("FONTNAME", (0, 2), (0, 2), "Helvetica-Bold"),
("FONTSIZE", (0, 2), (0, 2), 12),
("BACKGROUND", (1, 0), (1, 1), colors.Color(0.95, 0.97, 1.0)),
("TEXTCOLOR", (1, 0), (1, 1), colors.Color(0.2, 0.2, 0.2)),
("FONTNAME", (1, 0), (1, 1), "Helvetica"),
("BACKGROUND", (1, 2), (1, 2), compliance_color),
("TEXTCOLOR", (1, 2), (1, 2), colors.white),
("FONTNAME", (1, 2), (1, 2), "Helvetica-Bold"),
("FONTSIZE", (1, 2), (1, 2), 14),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("FONTSIZE", (0, 0), (1, 1), 11),
("GRID", (0, 0), (-1, -1), 1.5, colors.Color(0.5, 0.6, 0.7)),
("LEFTPADDING", (0, 0), (-1, -1), 12),
("RIGHTPADDING", (0, 0), (-1, -1), 12),
("TOPPADDING", (0, 0), (-1, -1), 10),
("BOTTOMPADDING", (0, 0), (-1, -1), 10),
]
)
)
elements.append(summary_table)
elements.append(PageBreak())
def get_weight(req):
req_id = req["id"]
attr = attrs_map.get(req_id, {})
metadata = attr.get("attributes", {}).get("metadata", [])
if metadata:
return metadata[0].get("Weight", 0)
return 0
sorted_reqs = sorted(resp_reqs, key=get_weight, reverse=True)
if only_failed:
sorted_reqs = [
req for req in sorted_reqs if req["attributes"]["status"] == "FAIL"
]
for req in sorted_reqs:
req_id = req["id"]
attr = attrs_map.get(req_id, {})
desc = req["attributes"]["description"]
@@ -274,7 +542,6 @@ def generate_compliance_report(
elements.append(Paragraph(f"{req_id}: {attr.get('description', desc)}", h1))
# Create visual status component
status_component = create_status_component(status)
elements.append(status_component)
elements.append(Spacer(1, 0.1 * inch))
@@ -287,10 +554,13 @@ def generate_compliance_report(
)
elements.append(Spacer(1, 0.1 * inch))
# Create visual risk component
risk_level = m.get("LevelOfRisk", 0)
weight = m.get("Weight", 0)
score = m.get("Score", 0)
if status == "PASS":
score = risk_level * weight
else:
score = 0
risk_component = create_risk_component(risk_level, weight, score)
elements.append(risk_component)
@@ -301,7 +571,9 @@ def generate_compliance_report(
elements.append(Paragraph(f"Check: {cid}", h2))
finds = get_finding_info(cid)
if not finds:
elements.append(Paragraph("- No", normal))
elements.append(
Paragraph("- No information for this finding currently", normal)
)
else:
for f in finds:
fid = f.get("id")
@@ -330,8 +602,18 @@ if __name__ == "__main__":
)
parser.add_argument("--email", required=True, help="Email for the API")
parser.add_argument("--password", required=True, help="Password for the API")
parser.add_argument(
"--only-failed",
action="store_true",
help="Only include failed requirements in the list of requirements",
)
args = parser.parse_args()
generate_compliance_report(
args.scan_id, args.compliance_id, args.output, args.email, args.password
args.scan_id,
args.compliance_id,
args.output,
args.email,
args.password,
args.only_failed,
)