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..212877a 100644
--- a/docksec/config.py
+++ b/docksec/config.py
@@ -73,17 +73,14 @@ 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:
- if os.path.exists(template_path):
+ 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()
- 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)}
"
+ 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..49376f3 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
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
-
-
-
-
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
-
- """
-
- 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 @@
@@ -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
+
+ {% if vulnerabilities | length > 50 %}
+
Showing 50 of {{ vulnerabilities | length }} vulnerabilities. See CSV/JSON for complete list.
+ {% endif %}
+
+ {% endif %}