diff --git a/docksec/report_generator.py b/docksec/report_generator.py index 1928178..d087d15 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -11,13 +11,14 @@ consistent data representation. """ -import os -import json import csv -import re +import json import logging -from typing import Dict, List, Optional +import os +import re from datetime import datetime +from typing import Dict, List, Optional + from fpdf import FPDF from docksec.config import RESULTS_DIR, html_template @@ -30,18 +31,18 @@ class ReportGenerator: """ Generates security scan reports in multiple formats. - + Supports: - JSON reports for machine-readable output - CSV reports for spreadsheet analysis - PDF reports for professional documentation - HTML reports for interactive viewing """ - + def __init__(self, image_name: str, results_dir: str = RESULTS_DIR): """ Initialize the report generator. - + Args: image_name: Name of the Docker image being scanned results_dir: Directory to store generated reports @@ -49,63 +50,62 @@ def __init__(self, image_name: str, results_dir: str = RESULTS_DIR): self.image_name = image_name self.results_dir = results_dir self.analysis_score: Optional[float] = None - + # Ensure results directory exists os.makedirs(self.results_dir, exist_ok=True) logger.info(f"ReportGenerator initialized for image: {image_name}") - + def set_analysis_score(self, score: float) -> None: """ Set the security analysis score for reports. - + Args: score: Security score (0-100) """ self.analysis_score = score logger.debug(f"Analysis score set to: {score}") - + def _get_safe_filename(self, extension: str) -> str: """ Generate a safe filename from image name. - + Args: extension: File extension (e.g., 'json', 'csv', 'pdf', 'html') - + Returns: Safe filename with proper extension """ - safe_name = re.sub(r'[:/.\-]', '_', self.image_name) + safe_name = re.sub(r"[:/.\-]", "_", self.image_name) return os.path.join(self.results_dir, f"{safe_name}_scan_results.{extension}") - + def generate_json_report(self, results: Dict) -> str: """ Generate JSON format report. - + Args: results: Scan results dictionary - + Returns: Path to the generated JSON file, or empty string on failure """ - output_file = self._get_safe_filename('json') + output_file = self._get_safe_filename("json") logger.info(f"Generating JSON report: {output_file}") - - json_results = results.get('json_data', []) + + json_results = results.get("json_data", []) report_data = { "scan_info": { "image": self.image_name, - "dockerfile": results.get('dockerfile_path', 'N/A'), - "scan_time": results.get('timestamp', datetime.now().strftime("%Y-%m-%d %H:%M:%S")), + "dockerfile": results.get("dockerfile_path", "N/A"), + "scan_time": results.get( + "timestamp", datetime.now().strftime("%Y-%m-%d %H:%M:%S") + ), "analysis_score": self.analysis_score, - "scan_mode": results.get('scan_mode', 'full') + "scan_mode": results.get("scan_mode", "full"), }, "vulnerabilities": json_results, - "summary": { - "total_vulnerabilities": len(json_results), - "by_severity": self._count_by_severity(json_results) - } + "severity_counts": self._count_by_severity(json_results), } - + try: with open(output_file, "w") as f: json.dump(report_data, f, indent=4) @@ -116,264 +116,315 @@ def generate_json_report(self, results: Dict) -> str: logger.error(f"Error saving JSON report: {e}", exc_info=True) print(f"[ERROR] Error saving JSON report: {e}") return "" - + def generate_csv_report(self, results: Dict) -> str: """ Generate CSV format report for vulnerability data. - + Args: results: Scan results dictionary - + Returns: Path to the generated CSV file, or empty string on failure """ - output_file = self._get_safe_filename('csv') + output_file = self._get_safe_filename("csv") logger.info(f"Generating CSV report: {output_file}") - - vulnerabilities = results.get('json_data', []) + + vulnerabilities = results.get("json_data", []) if not vulnerabilities: - logger.warning("No vulnerability data to save to CSV") - print("[WARNING] No vulnerability data to save to CSV") - return "" - + logger.warning( + "No vulnerability data to save to CSV, creating header-only file" + ) + print( + "[WARNING] No vulnerability data to save to CSV, creating header-only file" + ) + try: - fieldnames = [ - "VulnerabilityID", "Severity", "PkgName", "InstalledVersion", - "Title", "Description", "CVSS", "Status", "Target", "PrimaryURL" - ] - - with open(output_file, 'w', newline='') as csvfile: - writer = csv.DictWriter(csvfile, fieldnames=fieldnames) + # Map internal keys to expected CSV headers + header_mapping = { + "VulnerabilityID": "ID", + "Severity": "Severity", + "PkgName": "Package", + "InstalledVersion": "Version", + "Title": "Title", + "CVSS": "CVSS", + "Status": "Status", + "Target": "Target", + "PrimaryURL": "URL", + } + + with open(output_file, "w", newline="") as csvfile: + writer = csv.DictWriter( + csvfile, fieldnames=list(header_mapping.values()) + ) writer.writeheader() - + for vuln in vulnerabilities: - filtered_vuln = {k: vuln.get(k, "") for k in fieldnames} - writer.writerow(filtered_vuln) - - logger.info(f"CSV report saved successfully with {len(vulnerabilities)} vulnerabilities") + row = {header_mapping[k]: vuln.get(k, "") for k in header_mapping} + writer.writerow(row) + + logger.info( + f"CSV report saved successfully with {len(vulnerabilities)} vulnerabilities" + ) print(f"[SUCCESS] CSV report saved to {output_file}") return output_file - + except Exception as e: logger.error(f"Error saving CSV report: {e}", exc_info=True) print(f"[ERROR] Error saving CSV report: {e}") return "" - + def generate_pdf_report(self, results: Dict) -> str: """ Generate PDF format report with professional formatting. - + Args: results: Scan results dictionary - + Returns: Path to the generated PDF file, or empty string on failure """ - output_file = self._get_safe_filename('pdf') + output_file = self._get_safe_filename("pdf") logger.info(f"Generating PDF report: {output_file}") - + try: # Create custom PDF class with text wrapping class PDF(FPDF): def __init__(self): super().__init__() self.set_auto_page_break(True, margin=15) - + def multi_cell_with_title(self, title, content, title_w=40): """Create title-content pair with multi-line support""" - self.set_font('Arial', 'B', 10) + self.set_font("Arial", "B", 10) x_start = self.get_x() y_start = self.get_y() self.cell(title_w, 7, title) - self.set_font('Arial', '', 10) + self.set_font("Arial", "", 10) self.set_xy(x_start + title_w, y_start) - self.multi_cell(0, 7, content) + self.multi_cell(0, 7, content, new_x="LMARGIN", new_y="NEXT") self.ln(2) - + def add_section_header(self, title): """Add a section header""" - self.set_font('Arial', 'B', 12) + self.set_font("Arial", "B", 12) self.cell(0, 10, title, 0, 1) self.ln(2) - + pdf = PDF() pdf.add_page() - + # Title - pdf.set_font('Arial', 'B', 16) - scan_mode = results.get('scan_mode', 'full') - title = f'Docker Security Scan Report ({scan_mode.upper()})' - pdf.cell(0, 10, title, 0, 1, 'C') + pdf.set_font("Arial", "B", 16) + scan_mode = results.get("scan_mode", "full") + title = f"Docker Security Scan Report ({scan_mode.upper()})" + pdf.cell(0, 10, title, 0, 1, "C") pdf.ln(5) - + # Scan Information - pdf.add_section_header('Scan Information') - pdf.multi_cell_with_title('Image:', self.image_name) - pdf.multi_cell_with_title('Scan Mode:', scan_mode.replace('_', ' ').title()) - pdf.multi_cell_with_title('Dockerfile:', results.get('dockerfile_path', 'N/A')) - pdf.multi_cell_with_title('Scan Date:', results.get('timestamp', '')) - pdf.multi_cell_with_title('Analysis Score:', str(self.analysis_score)) + pdf.add_section_header("Scan Information") + pdf.multi_cell_with_title("Image:", self.image_name) + pdf.multi_cell_with_title("Scan Mode:", scan_mode.replace("_", " ").title()) + pdf.multi_cell_with_title( + "Dockerfile:", results.get("dockerfile_path", "N/A") + ) + pdf.multi_cell_with_title("Scan Date:", results.get("timestamp", "")) + pdf.multi_cell_with_title("Analysis Score:", str(self.analysis_score)) pdf.ln(5) - + # Dockerfile scan results (if not skipped) - if not results['dockerfile_scan'].get('skipped', False): - pdf.add_section_header('Dockerfile Scan Results') - - if results['dockerfile_scan']['success']: - pdf.set_font('Arial', '', 10) - pdf.cell(0, 7, 'No Dockerfile linting issues found.', 0, 1) + if not results["dockerfile_scan"].get("skipped", False): + pdf.add_section_header("Dockerfile Scan Results") + + if results["dockerfile_scan"]["success"]: + pdf.set_font("Arial", "", 10) + pdf.cell(0, 7, "No Dockerfile linting issues found.", 0, 1) else: - pdf.set_font('Arial', '', 10) - pdf.cell(0, 7, 'Dockerfile linting issues:', 0, 1) + pdf.set_font("Arial", "", 10) + pdf.cell(0, 7, "Dockerfile linting issues:", 0, 1) pdf.ln(2) - pdf.set_font('Courier', '', 8) - - if results['dockerfile_scan']['output']: - for line in results['dockerfile_scan']['output'].split('\n')[:20]: - pdf.multi_cell(0, 5, line) - + pdf.set_font("Courier", "", 8) + + if results["dockerfile_scan"]["output"]: + for line in results["dockerfile_scan"]["output"].split("\n")[ + :20 + ]: + pdf.multi_cell(0, 5, line, new_x="LMARGIN", new_y="NEXT") + pdf.ln(5) - + # Vulnerability summary - pdf.add_section_header('Vulnerability Summary') - vulnerabilities = results.get('json_data', []) - + pdf.add_section_header("Vulnerability Summary") + vulnerabilities = results.get("json_data", []) + if not vulnerabilities: - pdf.set_font('Arial', '', 10) - pdf.cell(0, 7, 'No vulnerabilities found.', 0, 1) + pdf.set_font("Arial", "", 10) + pdf.cell(0, 7, "No vulnerabilities found.", 0, 1) else: severity_counts = self._count_by_severity(vulnerabilities) - - pdf.set_font('Arial', '', 10) - pdf.cell(0, 7, f'Total vulnerabilities: {len(vulnerabilities)}', 0, 1) - + + pdf.set_font("Arial", "", 10) + pdf.cell(0, 7, f"Total vulnerabilities: {len(vulnerabilities)}", 0, 1) + for severity, count in severity_counts.items(): - pdf.cell(0, 7, f'{severity}: {count}', 0, 1) - + pdf.cell(0, 7, f"{severity}: {count}", 0, 1) + pdf.ln(5) - + # Top vulnerabilities if len(vulnerabilities) > 0: - pdf.add_section_header('Top Vulnerabilities') - + pdf.add_section_header("Top Vulnerabilities") + for i, vuln in enumerate(vulnerabilities[:20]): if pdf.get_y() > pdf.h - 40: pdf.add_page() - - pdf.set_font('Arial', 'B', 9) - pdf.cell(0, 6, f"{i+1}. {vuln.get('VulnerabilityID', 'N/A')} ({vuln.get('Severity', 'N/A')})", 0, 1) - - pdf.set_font('Arial', '', 8) - pdf.multi_cell(0, 4, f"Package: {vuln.get('PkgName', 'N/A')} ({vuln.get('InstalledVersion', 'N/A')})") - - title = vuln.get('Title', '') + + pdf.set_font("Arial", "B", 9) + pdf.cell( + 0, + 6, + f"{i+1}. {vuln.get('VulnerabilityID', 'N/A')} ({vuln.get('Severity', 'N/A')})", + 0, + 1, + ) + + pdf.set_font("Arial", "", 8) + pdf.multi_cell( + 0, + 4, + f"Package: {vuln.get('PkgName', 'N/A')} ({vuln.get('InstalledVersion', 'N/A')})", + new_x="LMARGIN", + new_y="NEXT", + ) + + title = vuln.get("Title", "") if title: - pdf.multi_cell(0, 4, f"Title: {title[:100]}{'...' if len(title) > 100 else ''}") - + pdf.multi_cell( + 0, + 4, + f"Title: {title[:100]}{'...' if len(title) > 100 else ''}", + new_x="LMARGIN", + new_y="NEXT", + ) + pdf.ln(2) - + if len(vulnerabilities) > 20: pdf.ln(3) - pdf.set_font('Arial', 'I', 9) - pdf.cell(0, 5, f'Showing 20 of {len(vulnerabilities)} vulnerabilities. See CSV/JSON for complete list.', 0, 1) - + pdf.set_font("Arial", "I", 9) + pdf.cell( + 0, + 5, + f"Showing 20 of {len(vulnerabilities)} vulnerabilities. See CSV/JSON for complete list.", + 0, + 1, + ) + pdf.output(output_file) logger.info(f"PDF report saved successfully") print(f"[SUCCESS] PDF report saved to {output_file}") return output_file - + except Exception as e: logger.error(f"Error saving PDF report: {e}", exc_info=True) print(f"[ERROR] Error saving PDF report: {e}") return "" - + def generate_html_report(self, results: Dict) -> str: """ Generate HTML format report with interactive features. - + Args: results: Scan results dictionary - + Returns: Path to the generated HTML file, or empty string on failure """ - output_file = self._get_safe_filename('html') + output_file = self._get_safe_filename("html") logger.info(f"Generating HTML report: {output_file}") - + try: template_vars = self._prepare_html_template_vars(results) - + # Replace placeholders in template html_content = html_template for key, value in template_vars.items(): - html_content = html_content.replace(f'{{{{{key}}}}}', str(value)) - + html_content = html_content.replace(f"{{{{{key}}}}}", str(value)) + # Save the HTML file - with open(output_file, 'w', encoding='utf-8') as f: + with open(output_file, "w", encoding="utf-8") as f: f.write(html_content) - + logger.info(f"HTML report saved successfully") print(f"[SUCCESS] HTML report saved to {output_file}") return output_file - + except Exception as e: logger.error(f"Error saving HTML report: {e}", exc_info=True) print(f"[ERROR] Error saving HTML report: {e}") return "" - + def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: """ Prepare variables for HTML template replacement. - + Args: results: Scan results dictionary - + Returns: Dictionary of template variables """ - vulnerabilities = results.get('json_data', []) - scan_mode = results.get('scan_mode', 'full') - + vulnerabilities = results.get("json_data", []) + scan_mode = results.get("scan_mode", "full") + template_vars = { - 'IMAGE_NAME': self.image_name, - 'SCAN_MODE': scan_mode.replace('_', ' ').title(), - 'SCAN_MODE_TITLE': f"{scan_mode.replace('_', ' ').title()} Scan", - 'DOCKERFILE_PATH': results.get('dockerfile_path', 'N/A'), - 'SCAN_DATE': results.get('timestamp', ''), - 'ANALYSIS_SCORE': str(self.analysis_score) if self.analysis_score else 'N/A' + "IMAGE_NAME": self.image_name, + "SCAN_MODE": scan_mode.replace("_", " ").title(), + "SCAN_MODE_TITLE": f"{scan_mode.replace('_', ' ').title()} Scan", + "DOCKERFILE_PATH": results.get("dockerfile_path", "N/A"), + "SCAN_DATE": results.get("timestamp", ""), + "ANALYSIS_SCORE": ( + str(self.analysis_score) if self.analysis_score else "N/A" + ), } - + # Security Score Section (placeholder for now) - template_vars['SECURITY_SCORE_SECTION'] = "" - template_vars['IMAGE_INFO_SECTION'] = "" - template_vars['CONFIG_ANALYSIS_SECTION'] = "" - + template_vars["SECURITY_SCORE_SECTION"] = "" + template_vars["IMAGE_INFO_SECTION"] = "" + template_vars["CONFIG_ANALYSIS_SECTION"] = "" + # Dockerfile Section - if not results['dockerfile_scan'].get('skipped', False): - if results['dockerfile_scan']['success']: - dockerfile_content = '
{self._escape_html(dockerfile_output[:2000])}'
if len(dockerfile_output) > 2000:
- dockerfile_content += 'Output truncated for display...
' - - template_vars['DOCKERFILE_SECTION'] = f""" + dockerfile_content += ( + "Output truncated for display...
" + ) + + template_vars["DOCKERFILE_SECTION"] = f"""Total vulnerabilities: {len(vulnerabilities)}
""" - - template_vars['VULNERABILITY_SUMMARY'] = severity_html - + + template_vars["VULNERABILITY_SUMMARY"] = severity_html + # Detailed vulnerabilities table table_html = """Showing 50 of {len(vulnerabilities)} vulnerabilities. See CSV/JSON for complete list.
' - - table_html += '