diff --git a/docksec/report_generator.py b/docksec/report_generator.py
index 07e7e66..1642a44 100644
--- a/docksec/report_generator.py
+++ b/docksec/report_generator.py
@@ -821,6 +821,13 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]:
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"]:
@@ -950,6 +957,55 @@ def _prepare_html_template_vars(self, results: Dict) -> Dict[str, str]:
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.
diff --git a/docksec/templates/report_template.html b/docksec/templates/report_template.html
index fe67c3d..7477902 100644
--- a/docksec/templates/report_template.html
+++ b/docksec/templates/report_template.html
@@ -386,6 +386,9 @@ Scan Information
{{CONFIG_ANALYSIS_SECTION}}
+
+ {{AI_ANALYSIS_SECTION}}
+
{{DOCKERFILE_SECTION}}
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 6845053..ceb0bb1 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- **HTML report dropped all AI findings**: the HTML report only rendered Trivy's package vulnerabilities, never the LLM findings (vulnerabilities, best practices, security risks, exposed credentials, remediation steps). For an AI-only run (Dockerfile analysis with no image) there are no Trivy vulnerabilities, so the HTML showed nothing but the security score even though the terminal reported dozens of findings. The complete AI findings now render in a dedicated "AI Dockerfile Analysis" section, so the report is the authoritative place to read the full list that the terminal only previews. (The PDF and JSON reports already carried these findings.)
- **`get_llm()` crashed with `NameError` on any LLM init failure**: the exception handler referenced a module-level `console` defined later in the file, so a bad API key / no credits / network error raised `NameError: name 'console' is not defined` instead of showing the troubleshooting steps. Now routed through the shared output layer.
## [2026.7.3] - 2026-07-02
diff --git a/tests/test_report_generator.py b/tests/test_report_generator.py
index 7240cce..caed856 100644
--- a/tests/test_report_generator.py
+++ b/tests/test_report_generator.py
@@ -256,6 +256,61 @@ def test_html_special_characters_are_escaped(tmp_path):
assert "<script>" in content
+def test_html_renders_full_ai_findings(tmp_path):
+ # AI-only runs have no Trivy vulnerabilities; the AI findings must still
+ # appear in full in the HTML (the terminal shows only a truncated preview).
+ results = make_results([])
+ results["scan_mode"] = "ai_only"
+ vulns = [f"AI vuln {i}" for i in range(1, 11)]
+ results["ai_findings"] = {
+ "vulnerabilities": vulns,
+ "best_practices": ["Pin the base image"],
+ "security_risks": ["Runs as root"],
+ "exposed_credentials": ["API_KEY=sk-prod-abc123"],
+ "remediation": ["Rotate the leaked key"],
+ }
+ rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path))
+ output_path = rg.generate_html_report(results)
+ with open(output_path, encoding="utf-8") as f:
+ content = f.read()
+
+ assert "AI Dockerfile Analysis
" in content
+ assert "Vulnerabilities (10)" in content
+ # Every finding is rendered, not just the terminal preview's first few.
+ for vuln in vulns:
+ assert vuln in content
+ assert "Pin the base image" in content
+ assert "API_KEY=sk-prod-abc123" in content
+ assert "Rotate the leaked key" in content
+
+
+def test_html_ai_findings_are_escaped(tmp_path):
+ results = make_results([])
+ results["scan_mode"] = "ai_only"
+ results["ai_findings"] = {
+ "vulnerabilities": [""],
+ "best_practices": [],
+ "security_risks": [],
+ "exposed_credentials": [],
+ "remediation": [],
+ }
+ rg = ReportGenerator(image_name="test-image", results_dir=str(tmp_path))
+ output_path = rg.generate_html_report(results)
+ with open(output_path, encoding="utf-8") as f:
+ content = f.read()
+ assert "