From 6b8026eaa04109c60b14adc80c3790ed58444fc5 Mon Sep 17 00:00:00 2001 From: rthakkar0555 Date: Mon, 18 May 2026 15:55:29 +0530 Subject: [PATCH 1/2] feat: implement multi-format report generator with comprehensive unit test suite --- docksec/report_generator.py | 488 +++++++++++++++++++-------------- scratch_fpdf.py | 20 ++ tests/conftest.py | 33 ++- tests/test_report_generator.py | 213 ++++++++++++++ 4 files changed, 538 insertions(+), 216 deletions(-) create mode 100644 scratch_fpdf.py create mode 100644 tests/test_report_generator.py 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 = '
No Dockerfile linting issues found
' + if not results["dockerfile_scan"].get("skipped", False): + if results["dockerfile_scan"]["success"]: + dockerfile_content = ( + '
No Dockerfile linting issues found
' + ) else: - dockerfile_output = results['dockerfile_scan'].get('output', '') + dockerfile_output = results["dockerfile_scan"].get("output", "") dockerfile_content = f'
{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"""

Dockerfile Scan Results

{dockerfile_content}
""" else: - template_vars['DOCKERFILE_SECTION'] = "" - + template_vars["DOCKERFILE_SECTION"] = "" + # Vulnerability Summary if not vulnerabilities: - template_vars['VULNERABILITY_SUMMARY'] = '
No vulnerabilities found
' - template_vars['DETAILED_VULNERABILITIES_SECTION'] = "" + template_vars["VULNERABILITY_SUMMARY"] = ( + '
No vulnerabilities found
' + ) + template_vars["DETAILED_VULNERABILITIES_SECTION"] = "" else: severity_counts = self._count_by_severity(vulnerabilities) - + severity_html = f"""
@@ -395,9 +446,9 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]:

Total vulnerabilities: {len(vulnerabilities)}

""" - - template_vars['VULNERABILITY_SUMMARY'] = severity_html - + + template_vars["VULNERABILITY_SUMMARY"] = severity_html + # Detailed vulnerabilities table table_html = """
@@ -416,18 +467,28 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: """ - + for vuln in vulnerabilities[:50]: - severity = vuln.get('Severity', 'UNKNOWN').lower() - severity_class = f'badge-{severity}' if severity in ['critical', 'high', 'medium', 'low'] else 'badge-low' - - status = vuln.get('Status', 'affected') - status_class = 'status-fixed' if status == 'fixed' else 'status-affected' - - cvss_score = vuln.get('CVSS', 'N/A') - if cvss_score and cvss_score != 'N/A': - cvss_score = f"{cvss_score:.1f}" if isinstance(cvss_score, (int, float)) else str(cvss_score) - + severity = vuln.get("Severity", "UNKNOWN").lower() + severity_class = ( + f"badge-{severity}" + if severity in ["critical", "high", "medium", "low"] + else "badge-low" + ) + + status = vuln.get("Status", "affected") + status_class = ( + "status-fixed" if status == "fixed" else "status-affected" + ) + + cvss_score = vuln.get("CVSS", "N/A") + if cvss_score and cvss_score != "N/A": + cvss_score = ( + f"{cvss_score:.1f}" + if isinstance(cvss_score, (int, float)) + else str(cvss_score) + ) + table_html += f""" {self._escape_html(vuln.get('VulnerabilityID', 'N/A'))} @@ -439,114 +500,115 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]: {status} """ - + table_html += """ """ - + if len(vulnerabilities) > 50: table_html += f'

Showing 50 of {len(vulnerabilities)} vulnerabilities. See CSV/JSON for complete list.

' - - table_html += '
' - template_vars['DETAILED_VULNERABILITIES_SECTION'] = table_html - + + table_html += "
" + template_vars["DETAILED_VULNERABILITIES_SECTION"] = table_html + return template_vars - + def _escape_html(self, text: str) -> str: """ Escape HTML special characters in text. - + Uses Python's built-in html.escape() for complete HTML5 entity handling, replacing the previous hand-rolled table. - + Args: text: Text to escape - + Returns: HTML-escaped text """ import html + if not text: return "" return html.escape(str(text), quote=True) - + def _count_by_severity(self, vulnerabilities: List[Dict]) -> Dict[str, int]: """ Count vulnerabilities by severity level. - + Args: vulnerabilities: List of vulnerability dictionaries - + Returns: Dictionary mapping severity to count """ - severity_counts = {'CRITICAL': 0, 'HIGH': 0, 'MEDIUM': 0, 'LOW': 0, 'UNKNOWN': 0} + severity_counts = { + "CRITICAL": 0, + "HIGH": 0, + "MEDIUM": 0, + "LOW": 0, + "UNKNOWN": 0, + } for vuln in vulnerabilities: - severity = vuln.get('Severity', 'UNKNOWN') + severity = vuln.get("Severity", "UNKNOWN") if severity in severity_counts: severity_counts[severity] += 1 else: - severity_counts['UNKNOWN'] += 1 + severity_counts["UNKNOWN"] += 1 return severity_counts - + def generate_all_reports(self, results: Dict) -> Dict[str, str]: """ Generate all report formats. - + Args: results: Scan results dictionary - + Returns: Dictionary mapping format to file path """ - from rich.progress import Progress, SpinnerColumn, TextColumn, BarColumn - + from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn + logger.info("Generating all report formats") print("\n=== Generating Reports ===") - - report_paths = { - 'json': '', - 'csv': '', - 'pdf': '', - 'html': '' - } - + + report_paths = {"json": "", "csv": "", "pdf": "", "html": ""} + with Progress( SpinnerColumn(), TextColumn("[progress.description]{task.description}"), BarColumn(), - console=None + console=None, ) as progress: # JSON report json_task = progress.add_task("[cyan]Generating JSON report...", total=1) json_path = self.generate_json_report(results) if json_path: - report_paths['json'] = json_path + report_paths["json"] = json_path progress.update(json_task, advance=1) - + # CSV report csv_task = progress.add_task("[cyan]Generating CSV report...", total=1) csv_path = self.generate_csv_report(results) if csv_path: - report_paths['csv'] = csv_path + report_paths["csv"] = csv_path progress.update(csv_task, advance=1) - + # PDF report pdf_task = progress.add_task("[cyan]Generating PDF report...", total=1) pdf_path = self.generate_pdf_report(results) if pdf_path: - report_paths['pdf'] = pdf_path + report_paths["pdf"] = pdf_path progress.update(pdf_task, advance=1) - + # HTML report html_task = progress.add_task("[cyan]Generating HTML report...", total=1) html_path = self.generate_html_report(results) if html_path: - report_paths['html'] = html_path + report_paths["html"] = html_path progress.update(html_task, advance=1) - + print("\n[SUCCESS] All reports generated successfully!") logger.info(f"All reports generated: {report_paths}") return report_paths - diff --git a/scratch_fpdf.py b/scratch_fpdf.py new file mode 100644 index 0000000..47ef209 --- /dev/null +++ b/scratch_fpdf.py @@ -0,0 +1,20 @@ +import sys +from fpdf import FPDF +try: + pdf = FPDF() + pdf.add_page() + pdf.set_font("helvetica", "B", 16) + pdf.cell(0, 10, "Title", new_x="LMARGIN", new_y="NEXT", align='C') + + pdf.set_font('helvetica', 'B', 9) + pdf.cell(0, 6, "1. CVE-2023-1234 (CRITICAL)", new_x="LMARGIN", new_y="NEXT") + + print("X before multi_cell 1:", pdf.get_x()) + pdf.set_font('helvetica', '', 8) + pdf.multi_cell(0, 4, "Package: openssl (1.0.0)", new_x="LMARGIN", new_y="NEXT") + + print("X before multi_cell 2:", pdf.get_x()) + pdf.multi_cell(0, 4, "Title: Buffer overflow in openssl", new_x="LMARGIN", new_y="NEXT") + print("Success!") +except Exception as e: + print(f"Error: {e}") diff --git a/tests/conftest.py b/tests/conftest.py index 7fe3741..7e71ef3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,8 +1,10 @@ """Pytest configuration and fixtures.""" -import pytest + import os -import tempfile import shutil +import tempfile + +import pytest @pytest.fixture @@ -17,7 +19,32 @@ def temp_dir(): def sample_dockerfile(temp_dir): """Create a sample Dockerfile for testing.""" dockerfile_path = os.path.join(temp_dir, "Dockerfile") - with open(dockerfile_path, 'w') as f: + with open(dockerfile_path, "w") as f: f.write("FROM ubuntu:latest\nRUN echo 'test'\n") return dockerfile_path + +@pytest.fixture +def sample_vulnerabilities(): + return [ + { + "VulnerabilityID": "CVE-2023-1234", + "Severity": "CRITICAL", + "PkgName": "openssl", + "InstalledVersion": "1.0.0", + "Title": "Buffer overflow in openssl", + "CVSS": 9.8, + "Status": "fixed", + "Target": "python:3.9-slim", + "PrimaryURL": "https://nvd.nist.gov/vuln/detail/CVE-2023-1234", + } + ] + + +@pytest.fixture +def sample_scan_info(): + return { + "image": "python:3.9-slim", + "scan_date": "2024-01-01T00:00:00", + "scanner": "trivy", + } diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py new file mode 100644 index 0000000..ccc6d4a --- /dev/null +++ b/tests/test_report_generator.py @@ -0,0 +1,213 @@ +# tests/test_report_generator.py +"""Unit tests for ReportGenerator covering JSON, CSV, PDF, and HTML report generation.""" + +import csv +import json +import os + +from docksec.report_generator import ReportGenerator + + +def make_results(vulnerabilities, scan_info=None): + """Helper to construct a minimal results dict for ReportGenerator methods.""" + if scan_info is None: + scan_info = { + "image": "python:3.9-slim", + "scan_date": "2024-01-01T00:00:00", + "scanner": "trivy", + } + return { + "json_data": vulnerabilities, + "dockerfile_path": "Dockerfile", + "timestamp": "2024-01-01T00:00:00", + "scan_mode": "full", + "dockerfile_scan": {"skipped": True, "success": True, "output": ""}, + } + + +# ---------- JSON REPORT TESTS ---------- + + +def test_json_report_file_is_created( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_json_report(results) + assert os.path.exists(output_path) + + +def test_json_report_has_required_keys( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_json_report(results) + with open(output_path) as f: + data = json.load(f) + for key in ["scan_info", "vulnerabilities", "severity_counts"]: + assert key in data + + +def test_json_severity_counts_are_correct( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_json_report(results) + with open(output_path) as f: + data = json.load(f) + counts = data["severity_counts"] + assert counts.get("CRITICAL", 0) == 1 + for sev in ["HIGH", "MEDIUM", "LOW", "UNKNOWN"]: + assert counts.get(sev, 0) == 0 + + +def test_json_empty_vulnerabilities_no_crash(tmp_path, sample_scan_info): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results([], sample_scan_info) + output_path = rg.generate_json_report(results) + assert os.path.exists(output_path) + with open(output_path) as f: + data = json.load(f) + assert data["vulnerabilities"] == [] + + +# ---------- CSV REPORT TESTS ---------- + + +def test_csv_report_file_is_created(tmp_path, sample_vulnerabilities, sample_scan_info): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_csv_report(results) + assert os.path.exists(output_path) + + +def test_csv_header_row_is_correct(tmp_path, sample_vulnerabilities, sample_scan_info): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_csv_report(results) + with open(output_path, newline="") as csvfile: + reader = csv.reader(csvfile) + header = next(reader) + expected = [ + "ID", + "Severity", + "Package", + "Version", + "Title", + "CVSS", + "Status", + "Target", + "URL", + ] + assert header == expected + + +def test_csv_vulnerability_data_maps_correctly( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_csv_report(results) + with open(output_path, newline="") as csvfile: + reader = csv.DictReader(csvfile) + rows = list(reader) + assert len(rows) == 1 + row = rows[0] + assert row["ID"] == "CVE-2023-1234" + assert row["Severity"] == "CRITICAL" + assert row["Package"] == "openssl" + + +def test_csv_empty_input_header_only(tmp_path, sample_scan_info): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results([], sample_scan_info) + output_path = rg.generate_csv_report(results) + assert os.path.exists(output_path) + with open(output_path, newline="") as csvfile: + reader = csv.reader(csvfile) + rows = list(reader) + assert len(rows) == 1 + expected = [ + "ID", + "Severity", + "Package", + "Version", + "Title", + "CVSS", + "Status", + "Target", + "URL", + ] + assert rows[0] == expected + + +# ---------- PDF REPORT TESTS ---------- + + +def test_pdf_report_file_is_created(tmp_path, sample_vulnerabilities, sample_scan_info): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_pdf_report(results) + assert os.path.exists(output_path) + + +def test_pdf_file_is_non_empty(tmp_path, sample_vulnerabilities, sample_scan_info): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_pdf_report(results) + assert os.path.getsize(output_path) > 0 + + +def test_pdf_no_exception_on_valid_input( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + rg.generate_pdf_report(results) + + +# ---------- HTML REPORT TESTS ---------- + + +def test_html_report_file_is_created( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_html_report(results) + assert os.path.exists(output_path) + + +def test_html_no_unfilled_placeholders( + tmp_path, sample_vulnerabilities, sample_scan_info +): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(sample_vulnerabilities, sample_scan_info) + output_path = rg.generate_html_report(results) + with open(output_path) as f: + content = f.read() + assert "{{" not in content + assert "}}" not in content + + +def test_html_special_characters_are_escaped(tmp_path): + vuln = { + "VulnerabilityID": "CVE-2023-9999", + "Severity": "HIGH", + "PkgName": "example", + "InstalledVersion": "1.2.3", + "Title": "", + "CVSS": 5.0, + "Status": "fixed", + "Target": "python:3.9-slim", + "PrimaryURL": "https://example.com", + } + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results([vuln]) + output_path = rg.generate_html_report(results) + with open(output_path) as f: + content = f.read() + assert "