From 7aacb0e1f1f6de7614ade93ac16febbc6d0e0dfe Mon Sep 17 00:00:00 2001 From: pedrooot Date: Wed, 11 Jun 2025 11:01:32 +0200 Subject: [PATCH] feat(compliance): improve pdf report --- .../compliance_report/compliance_generator.py | 390 +++++++++++++++--- 1 file changed, 336 insertions(+), 54 deletions(-) diff --git a/util/compliance_report/compliance_generator.py b/util/compliance_report/compliance_generator.py index 97938ee361..f7f056abe5 100644 --- a/util/compliance_report/compliance_generator.py +++ b/util/compliance_report/compliance_generator.py @@ -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: {compliance_id}", normal)) - elements.append(Paragraph(f"Scan ID: {scan_id}", normal)) - elements.append(Paragraph(f"Compliance Name: {compliance_name}", normal)) - elements.append( - Paragraph(f"Compliance Version: {compliance_version}", 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, )