From eb1df42f94b0db8130077ab7b73dff8f1aa0858b Mon Sep 17 00:00:00 2001 From: Jeffrey Shalom Date: Wed, 9 Sep 2026 09:46:45 +0530 Subject: [PATCH 1/2] feat: replace string-substitution HTML templates with Jinja2 (#51) --- MANIFEST.in | 2 +- docksec/config.py | 18 +- docksec/report_generator.py | 382 ++---------------- .../{report_template.html => report.html.j2} | 272 +++++++++++-- requirements.txt | 1 + setup.py | 3 +- tests/test_report_generator.py | 83 ++++ 7 files changed, 356 insertions(+), 405 deletions(-) rename docksec/templates/{report_template.html => report.html.j2} (51%) diff --git a/MANIFEST.in b/MANIFEST.in index dcce35f..5d3a814 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,6 +2,6 @@ include LICENSE include README.md include requirements.txt include .env.example -recursive-include docksec/templates *.html +recursive-include docksec/templates * recursive-include tests * global-exclude *.py[cod] __pycache__ *.so .DS_Store diff --git a/docksec/config.py b/docksec/config.py index b693f06..6f2fb2e 100644 --- a/docksec/config.py +++ b/docksec/config.py @@ -73,17 +73,15 @@ def get_html_template() -> str: """ Load the HTML report template from the templates directory. """ - template_path = os.path.join(TEMPLATES_DIR, "report_template.html") - try: + for filename in ("report.html.j2", "report_template.html"): + template_path = os.path.join(TEMPLATES_DIR, filename) if os.path.exists(template_path): - with open(template_path, 'r', encoding='utf-8') as f: - return f.read() - else: - # Fallback for when running from a location where templates might not be correctly linked - # This is a safety measure - return "

Docker Security Report

Template missing at " + template_path + "

" - except Exception as e: - return f"

Error

{str(e)}

" + try: + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + return f"

Error

{str(e)}

" + return "

Docker Security Report

Template missing in " + TEMPLATES_DIR + "

" # For backward compatibility with existing code that imports html_template diff --git a/docksec/report_generator.py b/docksec/report_generator.py index 6c82928..fde912b 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -21,8 +21,9 @@ from typing import Dict, List, Optional from docksec import output -from docksec.config import RESULTS_DIR, get_html_template +from docksec.config import RESULTS_DIR, TEMPLATES_DIR, get_html_template from docksec.utils import get_custom_logger +from jinja2 import Environment, FileSystemLoader, select_autoescape # fpdf2 emits a UserWarning at import time when the legacy PyFPDF package shares # the same module namespace. It is environmental noise that is not actionable @@ -701,12 +702,31 @@ def generate_html_report(self, results: Dict) -> str: logger.info(f"Generating HTML report: {output_file}") try: - template_vars = self._prepare_html_template_vars(results) + vulnerabilities = results.get("json_data", []) + scan_mode = results.get("scan_mode", "full") + severity_counts = self._count_by_severity(vulnerabilities) - # Replace placeholders in template - html_content = get_html_template() - for key, value in template_vars.items(): - html_content = html_content.replace(f"{{{{{key}}}}}", str(value)) + env = Environment( + loader=FileSystemLoader(TEMPLATES_DIR), + autoescape=select_autoescape(["html", "htm", "xml", "j2"]), + ) + template = env.get_template("report.html.j2") + html_content = template.render( + 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=self.analysis_score, + image_info=results.get("image_info"), + config_analysis=results.get("config_analysis"), + ai_findings=results.get("ai_findings"), + dockerfile_scan=results.get("dockerfile_scan", {"skipped": True}), + vulnerabilities=vulnerabilities, + severity_counts=severity_counts, + suppressed_count=results.get("suppressed_count"), + ignore_file=results.get("ignore_file"), + ) # Save the HTML file with open(output_file, "w", encoding="utf-8") as f: @@ -844,355 +864,6 @@ def generate_markdown_report(self, results: Dict) -> str: output.error(f"Failed to save Markdown 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") - - 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" - ), - } - - # Security Score Section (rating bands match the terminal summary in - # docksec.output._score_band) - score_rating_html = "" - if self.analysis_score is not None: - score = float(self.analysis_score) - if score >= 90: - rating, rating_class = "Excellent", "rating-excellent" - elif score >= 70: - rating, rating_class = "Good", "rating-good" - elif score >= 50: - rating, rating_class = "Fair", "rating-fair" - else: - rating, rating_class = "Poor", "rating-poor" - score_rating_html = f'
{rating}
' - - template_vars["SECURITY_SCORE_SECTION"] = f""" -
-

Security Score

-
-
Overall Security Score
-
{self.analysis_score if self.analysis_score is not None else 'N/A'}/100
- {score_rating_html} -
-
- """ - - # Image Information Section - if "image_info" in results: - image_info = results["image_info"] - size_mb = ( - round(image_info.get("size", 0) / (1024 * 1024), 2) - if image_info.get("size") - else "N/A" - ) - - template_vars["IMAGE_INFO_SECTION"] = f""" -
-

Image Information

-
-
-
Size
-
{size_mb} MB
-
-
-
Created
-
{image_info.get('created', 'N/A')[:19]}
-
-
-
Architecture
-
{image_info.get('architecture', 'N/A')}
-
-
-
OS
-
{image_info.get('os', 'N/A')}
-
-
-
- """ - else: - template_vars["IMAGE_INFO_SECTION"] = "" - - # Configuration Analysis Section - if "config_analysis" in results: - config_analysis = results["config_analysis"] - config_html = ( - '

Configuration Analysis

' - ) - - # High risk issues - if config_analysis.get("high_risk"): - config_html += '

High-Risk Issues

    ' - for issue in config_analysis["high_risk"]: - config_html += f"
  • {self._escape_html(issue)}
  • " - config_html += "
" - - # Medium risk issues - if config_analysis.get("medium_risk"): - config_html += '

Medium-Risk Issues

    ' - for issue in config_analysis["medium_risk"]: - config_html += f"
  • {self._escape_html(issue)}
  • " - config_html += "
" - - # Low risk issues - if config_analysis.get("low_risk"): - config_html += '

Low-Risk Issues

    ' - for issue in config_analysis["low_risk"]: - config_html += f"
  • {self._escape_html(issue)}
  • " - config_html += "
" - - config_html += "
" - template_vars["CONFIG_ANALYSIS_SECTION"] = config_html - else: - template_vars["CONFIG_ANALYSIS_SECTION"] = "" - - # AI Dockerfile Analysis Section. Renders the full LLM findings (the - # terminal shows only a truncated preview), so this is where the user - # reads the complete list. Empty when no AI analysis ran. - template_vars["AI_ANALYSIS_SECTION"] = self._build_ai_analysis_html( - results.get("ai_findings") - ) - - # Dockerfile Section - 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_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 Scan Results

- {dockerfile_content} -
- """ - else: - template_vars["DOCKERFILE_SECTION"] = "" - - # Vulnerability Summary - if not vulnerabilities: - no_issues_html = '
No vulnerabilities found
' - suppressed = results.get("suppressed_count") - if suppressed: - ignore_file = self._escape_html(str(results.get("ignore_file", ""))) - no_issues_html += ( - f"

Waived: {suppressed} triaged finding(s) " - f"suppressed via ignore file {ignore_file}

" - ) - template_vars["VULNERABILITY_SUMMARY"] = no_issues_html - template_vars["DETAILED_VULNERABILITIES_SECTION"] = "" - else: - severity_counts = self._count_by_severity(vulnerabilities) - - severity_html = f""" -
-
-
{severity_counts.get('CRITICAL', 0)}
-
Critical
-
-
-
{severity_counts.get('HIGH', 0)}
-
High
-
-
-
{severity_counts.get('MEDIUM', 0)}
-
Medium
-
-
-
{severity_counts.get('LOW', 0)}
-
Low
-
-
-

Total vulnerabilities: {len(vulnerabilities)}

- """ - - fixable = sum(1 for v in vulnerabilities if v.get("FixedVersion")) - if fixable: - severity_html += ( - f"

Fix available: {fixable} of " - f"{len(vulnerabilities)} findings have a fixed version upstream

" - ) - suppressed = results.get("suppressed_count") - if suppressed: - ignore_file = self._escape_html(str(results.get("ignore_file", ""))) - severity_html += ( - f"

Waived: {suppressed} triaged finding(s) " - f"suppressed via ignore file {ignore_file}

" - ) - - template_vars["VULNERABILITY_SUMMARY"] = severity_html - - # Detailed vulnerabilities table - table_html = """ -
-

Detailed Vulnerabilities

-
- - - - - - - - - - - - - - - """ - - 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) - ) - - vuln_id = vuln.get('VulnerabilityID') or 'N/A' - pkg_name = vuln.get('PkgName') or 'N/A' - installed_version = vuln.get('InstalledVersion') or 'N/A' - fixed_version = vuln.get('FixedVersion') or '' - fixed_cell = ( - f'{self._escape_html(fixed_version)}' - if fixed_version else 'none yet' - ) - title = vuln.get('Title') or 'N/A' - display_title = (title[:80] + '...') if len(title) > 80 else title - - table_html += f""" - - - - - - - - - - - """ - - table_html += """ - -
IDSeverityPackageInstalledFixed InTitleCVSSStatus
{self._escape_html(vuln_id)}{vuln.get('Severity', 'N/A')}{self._escape_html(pkg_name)}{self._escape_html(installed_version)}{fixed_cell}{self._escape_html(display_title)}{cvss_score}{status}
-
- """ - - 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 - - return template_vars - - def _build_ai_analysis_html(self, ai_findings: Optional[Dict]) -> str: - """ - Build the AI Dockerfile Analysis HTML section from LLM findings. - - Renders every finding in full (unlike the truncated terminal preview) - so the report is the authoritative place to read the complete list. - - Args: - ai_findings: The "ai_findings" dict produced by analyze_security, - or None when no AI analysis ran. - - Returns: - HTML string for the section, or "" when there are no findings. - """ - if not ai_findings: - return "" - - # (key, heading, config-list severity class) for each category. - categories = [ - ("vulnerabilities", "Vulnerabilities", "high"), - ("security_risks", "Security Risks", "high"), - ("exposed_credentials", "Exposed Credentials", "high"), - ("best_practices", "Best Practices", "medium"), - ("remediation", "Remediation Steps", "low"), - ] - - blocks = [] - for key, heading, list_class in categories: - items = ai_findings.get(key) or [] - if not items: - continue - list_items = "".join( - f"
  • {self._escape_html(str(item))}
  • " for item in items - ) - blocks.append( - f'
    ' - f"

    {self._escape_html(heading)} ({len(items)})

    " - f'' - f"
    " - ) - - if not blocks: - return "" - - return ( - '

    AI Dockerfile Analysis

    ' - '
    ' + "".join(blocks) + "
    " - ) - - 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 _escape_markdown(self, text) -> str: """ Make text safe for Markdown tables. @@ -1214,7 +885,6 @@ def _escape_markdown(self, text) -> str: .replace("|", "\\|") .replace("\n", " ") ) - def _count_by_severity(self, vulnerabilities: List[Dict]) -> Dict[str, int]: """ Count vulnerabilities by severity level. diff --git a/docksec/templates/report_template.html b/docksec/templates/report.html.j2 similarity index 51% rename from docksec/templates/report_template.html rename to docksec/templates/report.html.j2 index 4fef3a5..e935c24 100644 --- a/docksec/templates/report_template.html +++ b/docksec/templates/report.html.j2 @@ -246,6 +246,26 @@ .status-fixed { background: rgba(5, 150, 105, 0.14); color: var(--ok); } .status-affected { background: rgba(220, 38, 38, 0.12); color: var(--critical); } + .fixed-version { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 0.88em; + color: var(--ok); + font-weight: 600; + } + + .no-fix { + color: var(--text-muted); + font-style: italic; + font-size: 0.88em; + } + + .table-note { + margin-top: 15px; + font-style: italic; + color: var(--text-muted); + font-size: 0.9em; + } + .no-issues { text-align: center; padding: 32px 20px; @@ -295,39 +315,20 @@ .score-rating { display: inline-block; - margin-top: 10px; + margin-top: 8px; padding: 4px 14px; border-radius: 999px; - font-size: 0.8em; + font-size: 0.82em; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.06em; } .rating-excellent { background: rgba(5, 150, 105, 0.14); color: var(--ok); } - .rating-good { background: rgba(5, 150, 105, 0.10); color: var(--ok); } + .rating-good { background: rgba(37, 99, 235, 0.12); color: var(--low); } .rating-fair { background: rgba(217, 119, 6, 0.14); color: var(--medium); } .rating-poor { background: rgba(220, 38, 38, 0.12); color: var(--critical); } - .fixed-version { color: var(--ok); font-weight: 600; } - .no-fix { color: var(--text-muted); font-style: italic; } - - .table-note { - margin-top: 15px; - font-style: italic; - color: var(--text-muted); - } - - .mono-block { - background: var(--surface-alt); - border: 1px solid var(--border); - color: var(--text); - padding: 15px; - border-radius: 8px; - overflow-x: auto; - font-size: 0.9em; - } - .config-issues { margin-top: 8px; } .config-category { margin-bottom: 18px; } .config-category:last-child { margin-bottom: 0; } @@ -354,6 +355,14 @@ .config-list.medium li { border-left-color: var(--medium); } .config-list.low li { border-left-color: var(--low); } + .mono-block { + background: #f8f9fa; + padding: 15px; + border-radius: 5px; + overflow-x: auto; + font-size: 0.9em; + } + .footer { text-align: center; padding: 24px 40px; @@ -381,7 +390,7 @@
    DockSec Security Report

    Docker Security Report

    -

    {{SCAN_MODE_TITLE}}

    +

    {{ scan_mode_title }}

    @@ -391,54 +400,243 @@

    Scan Information

    Image Name
    -
    {{IMAGE_NAME}}
    +
    {{ image_name }}
    Scan Mode
    -
    {{SCAN_MODE}}
    +
    {{ scan_mode }}
    Dockerfile Path
    -
    {{DOCKERFILE_PATH}}
    +
    {{ dockerfile_path }}
    Scan Date
    -
    {{SCAN_DATE}}
    +
    {{ scan_date }}
    Analysis Score
    -
    {{ANALYSIS_SCORE}}
    +
    {{ analysis_score if analysis_score is not none else 'N/A' }}
    - {{SECURITY_SCORE_SECTION}} +
    +

    Security Score

    +
    +
    Overall Security Score
    +
    {{ analysis_score if analysis_score is not none else 'N/A' }}/100
    + {% if analysis_score is not none %} + {% set score = analysis_score | float %} + {% if score >= 90 %} +
    Excellent
    + {% elif score >= 70 %} +
    Good
    + {% elif score >= 50 %} +
    Fair
    + {% else %} +
    Poor
    + {% endif %} + {% endif %} +
    +
    - {{IMAGE_INFO_SECTION}} + {% if image_info %} +
    +

    Image Information

    +
    +
    +
    Size
    +
    {{ (image_info.size / 1048576) | round(2) if image_info.get('size') else 'N/A' }} MB
    +
    +
    +
    Created
    +
    {{ image_info.get('created', 'N/A')[:19] }}
    +
    +
    +
    Architecture
    +
    {{ image_info.get('architecture', 'N/A') }}
    +
    +
    +
    OS
    +
    {{ image_info.get('os', 'N/A') }}
    +
    +
    +
    + {% endif %} - {{CONFIG_ANALYSIS_SECTION}} + {% if config_analysis and (config_analysis.get('high_risk') or config_analysis.get('medium_risk') or config_analysis.get('low_risk')) %} +
    +

    Configuration Analysis

    +
    + {% if config_analysis.get('high_risk') %} +
    +

    High-Risk Issues

    +
      + {% for issue in config_analysis.high_risk %} +
    • {{ issue }}
    • + {% endfor %} +
    +
    + {% endif %} + {% if config_analysis.get('medium_risk') %} +
    +

    Medium-Risk Issues

    +
      + {% for issue in config_analysis.medium_risk %} +
    • {{ issue }}
    • + {% endfor %} +
    +
    + {% endif %} + {% if config_analysis.get('low_risk') %} +
    +

    Low-Risk Issues

    +
      + {% for issue in config_analysis.low_risk %} +
    • {{ issue }}
    • + {% endfor %} +
    +
    + {% endif %} +
    +
    + {% endif %} - {{AI_ANALYSIS_SECTION}} + {% if ai_findings and (ai_findings.get('vulnerabilities') or ai_findings.get('security_risks') or ai_findings.get('exposed_credentials') or ai_findings.get('best_practices') or ai_findings.get('remediation')) %} +
    +

    AI Dockerfile Analysis

    +
    + {% for key, heading, list_class in [ + ('vulnerabilities', 'Vulnerabilities', 'high'), + ('security_risks', 'Security Risks', 'high'), + ('exposed_credentials', 'Exposed Credentials', 'high'), + ('best_practices', 'Best Practices', 'medium'), + ('remediation', 'Remediation Steps', 'low') + ] %} + {% if ai_findings.get(key) %} +
    +

    {{ heading }} ({{ ai_findings[key] | length }})

    +
      + {% for item in ai_findings[key] %} +
    • {{ item }}
    • + {% endfor %} +
    +
    + {% endif %} + {% endfor %} +
    +
    + {% endif %} - {{DOCKERFILE_SECTION}} + {% if dockerfile_scan and not dockerfile_scan.get('skipped', False) %} +
    +

    Dockerfile Scan Results

    + {% if dockerfile_scan.get('success') %} +
    No Dockerfile linting issues found
    + {% else %} + {% set df_output = dockerfile_scan.get('output', '') %} +
    {{ df_output[:2000] }}
    + {% if df_output | length > 2000 %} +

    Output truncated for display...

    + {% endif %} + {% endif %} +
    + {% endif %}

    Vulnerability Summary

    - {{VULNERABILITY_SUMMARY}} + {% if not vulnerabilities %} +
    No vulnerabilities found
    + {% if suppressed_count %} +

    Waived: {{ suppressed_count }} triaged finding(s) suppressed via ignore file {{ ignore_file }}

    + {% endif %} + {% else %} +
    +
    +
    {{ severity_counts.get('CRITICAL', 0) }}
    +
    Critical
    +
    +
    +
    {{ severity_counts.get('HIGH', 0) }}
    +
    High
    +
    +
    +
    {{ severity_counts.get('MEDIUM', 0) }}
    +
    Medium
    +
    +
    +
    {{ severity_counts.get('LOW', 0) }}
    +
    Low
    +
    +
    +

    Total vulnerabilities: {{ vulnerabilities | length }}

    + {% set fixable = vulnerabilities | selectattr('FixedVersion', 'defined') | selectattr('FixedVersion') | list | length %} + {% if fixable > 0 %} +

    Fix available: {{ fixable }} of {{ vulnerabilities | length }} findings have a fixed version upstream

    + {% endif %} + {% if suppressed_count %} +

    Waived: {{ suppressed_count }} triaged finding(s) suppressed via ignore file {{ ignore_file }}

    + {% endif %} + {% endif %}
    + {% if vulnerabilities %} - {{DETAILED_VULNERABILITIES_SECTION}} +
    +

    Detailed Vulnerabilities

    +
    + + + + + + + + + + + + + + + {% for vuln in vulnerabilities[:50] %} + {% set severity = (vuln.get('Severity') or 'UNKNOWN') | lower %} + {% set severity_class = 'badge-' ~ severity if severity in ['critical', 'high', 'medium', 'low'] else 'badge-low' %} + {% set status = vuln.get('Status') or 'affected' %} + {% set status_class = 'status-fixed' if status == 'fixed' else 'status-affected' %} + {% set title = vuln.get('Title') or 'N/A' %} + {% set cvss_val = vuln.get('CVSS') %} + {% set fixed_version = vuln.get('FixedVersion') %} + + + + + + + + + + + {% endfor %} + +
    IDSeverityPackageInstalledFixed InTitleCVSSStatus
    {{ vuln.get('VulnerabilityID') or 'N/A' }}{{ vuln.get('Severity') or 'N/A' }}{{ vuln.get('PkgName') or 'N/A' }}{{ vuln.get('InstalledVersion') or 'N/A' }}{% if fixed_version %}{{ fixed_version }}{% else %}none yet{% endif %}{{ (title[:80] ~ '...') if title | length > 80 else title }}{{ "%.1f" | format(cvss_val) if cvss_val is number else (cvss_val if cvss_val else 'N/A') }}{{ status }}
    +
    + {% if vulnerabilities | length > 50 %} +

    Showing 50 of {{ vulnerabilities | length }} vulnerabilities. See CSV/JSON for complete list.

    + {% endif %} +
    + {% endif %} diff --git a/requirements.txt b/requirements.txt index 70f46fc..ac466ae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ python-dotenv>=1.0,<2 colorama>=0.4.6,<1 rich>=13.0,<16 fpdf2>=2.8,<3 +jinja2>=3.1.0 ruamel.yaml>=0.18.6 setuptools>=82.0.1 diff --git a/setup.py b/setup.py index 2ff958c..c2c489a 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ "fpdf2>=2.8,<3", "setuptools>=65.0.0", "ruamel.yaml>=0.18.6", + "jinja2>=3.1.0", ], extras_require={ "dev": [ @@ -60,6 +61,6 @@ ], include_package_data=True, package_data={ - 'docksec': ['templates/*.html'], + 'docksec': ['templates/*'], }, ) diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py index 18a3263..7aee7c1 100644 --- a/tests/test_report_generator.py +++ b/tests/test_report_generator.py @@ -364,6 +364,89 @@ def test_html_omits_ai_section_without_findings(tmp_path): assert "

    AI Dockerfile Analysis

    " not in content +def test_html_renders_vulnerabilities_table_and_truncation(tmp_path): + vulns = [ + { + "VulnerabilityID": f"CVE-2024-{1000 + i}", + "Severity": "CRITICAL" if i % 2 == 0 else "HIGH", + "PkgName": f"package-{i}", + "InstalledVersion": f"1.0.{i}", + "Title": f"Vulnerability title for issue {i}", + "CVSS": 8.5, + "Status": "fixed" if i % 2 == 0 else "affected", + } + for i in range(60) + ] + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results(vulns) + output_path = rg.generate_html_report(results) + with open(output_path, encoding="utf-8") as f: + content = f.read() + + assert "

    Detailed Vulnerabilities

    " in content + assert "Total vulnerabilities: 60" in content + assert "Showing 50 of 60 vulnerabilities" in content + assert "CVE-2024-1000" in content + assert "CVE-2024-1049" in content + # The 51st vulnerability (index 50, id 1050) should not appear in the table + assert "CVE-2024-1050" not in content + + +def test_html_empty_vulnerabilities_renders_success_state(tmp_path): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results([]) + output_path = rg.generate_html_report(results) + with open(output_path, encoding="utf-8") as f: + content = f.read() + + assert "No vulnerabilities found" in content + assert "

    Detailed Vulnerabilities

    " not in content + + +def test_html_renders_image_info_and_config_analysis(tmp_path): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results([]) + results["image_info"] = { + "size": 104857600, # 100 MB + "created": "2026-01-01T12:00:00Z", + "architecture": "arm64", + "os": "alpine", + } + results["config_analysis"] = { + "high_risk": ["Root user configured"], + "medium_risk": ["Missing HEALTHCHECK"], + "low_risk": ["No label provided"], + } + output_path = rg.generate_html_report(results) + with open(output_path, encoding="utf-8") as f: + content = f.read() + + assert "

    Image Information

    " in content + assert "100.0 MB" in content + assert "arm64" in content + assert "alpine" in content + assert "

    Configuration Analysis

    " in content + assert "Root user configured" in content + assert "Missing HEALTHCHECK" in content + assert "No label provided" in content + + +def test_html_renders_dockerfile_scan_results(tmp_path): + rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path)) + results = make_results([]) + results["dockerfile_scan"] = { + "skipped": False, + "success": False, + "output": "DL3006 Always tag the version of an image explicitly", + } + output_path = rg.generate_html_report(results) + with open(output_path, encoding="utf-8") as f: + content = f.read() + + assert "

    Dockerfile Scan Results

    " in content + assert "DL3006 Always tag the version of an image explicitly" in content + + # ---------- MARKDOWN REPORT TESTS ---------- From 741a9e6276bc52022b4569eb515149fc228b2fc5 Mon Sep 17 00:00:00 2001 From: Jeffrey Shalom Date: Wed, 9 Sep 2026 22:00:28 +0530 Subject: [PATCH 2/2] fix: remove unused import and improve get_html_template coverage --- docksec/config.py | 15 +++++++-------- docksec/report_generator.py | 2 +- tests/test_config.py | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/docksec/config.py b/docksec/config.py index 6f2fb2e..212877a 100644 --- a/docksec/config.py +++ b/docksec/config.py @@ -73,14 +73,13 @@ def get_html_template() -> str: """ Load the HTML report template from the templates directory. """ - for filename in ("report.html.j2", "report_template.html"): - template_path = os.path.join(TEMPLATES_DIR, filename) - if os.path.exists(template_path): - try: - with open(template_path, 'r', encoding='utf-8') as f: - return f.read() - except Exception as e: - return f"

    Error

    {str(e)}

    " + template_path = os.path.join(TEMPLATES_DIR, "report.html.j2") + if os.path.exists(template_path): + try: + with open(template_path, 'r', encoding='utf-8') as f: + return f.read() + except Exception as e: + return f"

    Error

    {str(e)}

    " return "

    Docker Security Report

    Template missing in " + TEMPLATES_DIR + "

    " diff --git a/docksec/report_generator.py b/docksec/report_generator.py index fde912b..49376f3 100644 --- a/docksec/report_generator.py +++ b/docksec/report_generator.py @@ -21,7 +21,7 @@ from typing import Dict, List, Optional from docksec import output -from docksec.config import RESULTS_DIR, TEMPLATES_DIR, get_html_template +from docksec.config import RESULTS_DIR, TEMPLATES_DIR from docksec.utils import get_custom_logger from jinja2 import Environment, FileSystemLoader, select_autoescape diff --git a/tests/test_config.py b/tests/test_config.py index 75a949a..31469f5 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -169,6 +169,24 @@ def test_get_html_template(self): self.assertIn("