Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions docksec/report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down Expand Up @@ -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"<li>{self._escape_html(str(item))}</li>" for item in items
)
blocks.append(
f'<div class="config-category">'
f"<h4>{self._escape_html(heading)} ({len(items)})</h4>"
f'<ul class="config-list {list_class}">{list_items}</ul>'
f"</div>"
)

if not blocks:
return ""

return (
'<div class="section"><h2>AI Dockerfile Analysis</h2>'
'<div class="config-issues">' + "".join(blocks) + "</div></div>"
)

def _escape_html(self, text: str) -> str:
"""
Escape HTML special characters in text.
Expand Down
3 changes: 3 additions & 0 deletions docksec/templates/report_template.html
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,9 @@ <h2>Scan Information</h2>
<!-- Configuration Analysis Section -->
{{CONFIG_ANALYSIS_SECTION}}

<!-- AI Dockerfile Analysis Section -->
{{AI_ANALYSIS_SECTION}}

<!-- Dockerfile Scan Results Section -->
{{DOCKERFILE_SECTION}}

Expand Down
1 change: 1 addition & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
55 changes: 55 additions & 0 deletions tests/test_report_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,61 @@ def test_html_special_characters_are_escaped(tmp_path):
assert "&lt;script&gt;" 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 "<h2>AI Dockerfile Analysis</h2>" 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": ["<script>alert('xss')</script>"],
"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 "<script>alert" not in content
assert "&lt;script&gt;" in content


def test_html_omits_ai_section_without_findings(tmp_path):
results = make_results([]) # no ai_findings 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 "<h2>AI Dockerfile Analysis</h2>" not in content


# ---------- FORMAT SELECTION TESTS ----------


Expand Down
Loading