Skip to content
Open
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
7 changes: 6 additions & 1 deletion stellargate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

from stellargate.aggregator import all_findings, run_all
from stellargate.config import VALID_SEVERITIES, Config, ConfigError
from stellargate.report import gate_passed, to_json, to_markdown
from stellargate.report import gate_passed, to_html, to_json, to_markdown

logger = logging.getLogger("stellargate")

Expand Down Expand Up @@ -57,6 +57,7 @@ def main(argv: list[str] | None = None) -> int:
run_parser.add_argument("--config", default="stellargate.yaml")
run_parser.add_argument("--json-report", default=None, help="Write JSON report to this path")
run_parser.add_argument("--md-report", default=None, help="Write Markdown report to this path")
run_parser.add_argument("--html-report", default=None, help="Write HTML report to this path")
run_parser.add_argument("--fail-on", default=None, help="Override fail_on threshold from config")

args = parser.parse_args(argv)
Expand Down Expand Up @@ -117,6 +118,10 @@ def _run(args: argparse.Namespace) -> int:
with open(args.md_report, "w") as f:
f.write(to_markdown(results, fail_on, passed))

if args.html_report:
with open(args.html_report, "w") as f:
f.write(to_html(results, fail_on, passed))

return 0 if passed else 1


Expand Down
103 changes: 102 additions & 1 deletion stellargate/report.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""JSON and Markdown compliance report generation."""
"""JSON, Markdown, and HTML compliance report generation."""
from __future__ import annotations

from datetime import datetime, timezone
from html import escape

from stellargate.aggregator import ToolRunResult
from stellargate.schema import SEVERITY_ORDER, Finding
Expand Down Expand Up @@ -70,6 +71,106 @@ def to_markdown(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -
return "\n".join(lines)


def to_html(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> str:
"""Render a self-contained styled HTML report (no external CSS/JS)."""
findings = [f for r in results for f in r.findings]
counts = {sev: 0 for sev in SEVERITY_ORDER}
for f in findings:
counts[f.severity] += 1

status = "PASSED" if gate_passed else "FAILED"
status_class = "pass" if gate_passed else "fail"
generated_at = datetime.now(timezone.utc).isoformat(timespec="seconds")

tool_rows = []
for r in results:
if r.error:
tool_rows.append(
f"<tr><td>{escape(r.tool)}</td>"
'<td class="error">&#9888; error</td><td>&mdash;</td></tr>'
)
else:
tool_rows.append(
f"<tr><td>{escape(r.tool)}</td>"
f'<td class="ok">ok</td><td>{len(r.findings)}</td></tr>'
)

error_rows = [
f"<li><strong>{escape(r.tool)}</strong>: {escape(r.error)}</li>"
for r in results
if r.error
]

finding_rows = [
(
"<tr>"
f"<td><span class=\"sev sev-{escape(f.severity)}\">{escape(f.severity)}</span></td>"
f"<td>{escape(f.tool)}</td>"
f"<td>{escape(f.rule_id)}</td>"
f"<td>{escape(f.location) if f.location else '&mdash;'}</td>"
f"<td>{escape(f.message)}</td>"
"</tr>"
)
for f in findings
]

parts = [
"<!DOCTYPE html>",
'<html lang="en">',
"<head>",
'<meta charset="utf-8">',
"<title>StellarGate Compliance Report</title>",
"<style>",
"body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,"
"Helvetica,Arial,sans-serif;max-width:960px;margin:2rem auto;padding:0 1rem;"
"color:#1f2328;line-height:1.5}",
"h1{font-size:1.5rem}.badge{display:inline-block;padding:.3rem .8rem;"
"border-radius:4px;color:#fff;font-weight:600}",
".badge.pass{background:#1a7f37}.badge.fail{background:#cf222e}",
"table{border-collapse:collapse;width:100%;margin:.5rem 0 1.5rem}",
"th,td{border:1px solid #d0d7de;padding:.4rem .6rem;text-align:left;font-size:.9rem}",
"th{background:#f6f8fa}td.ok{color:#1a7f37}td.error{color:#cf222e}",
".sev{padding:.1rem .4rem;border-radius:3px;color:#fff;font-size:.8rem}",
".sev-critical{background:#cf222e}.sev-high{background:#bc4c00}",
".sev-medium{background:#9a6700}.sev-low{background:#57606a}",
"ul.errors{color:#cf222e}code{background:#f6f8fa;padding:.1rem .3rem;"
"border-radius:3px}",
"</style>",
"</head>",
"<body>",
f"<h1>StellarGate Compliance Report &mdash; "
f'<span class="badge {status_class}">{status}</span></h1>',
f"<p>Threshold: fail on <strong>{escape(fail_on)}</strong> or above. "
f"Generated at <code>{escape(generated_at)}</code>.</p>",
f'<h2>Per-tool summary <span style="font-weight:normal;font-size:.9rem">'
f"(critical {counts['critical']}, high {counts['high']}, "
f"medium {counts['medium']}, low {counts['low']})</span></h2>",
"<table><thead><tr><th>Tool</th><th>Status</th><th>Findings</th></tr></thead>",
"<tbody>",
*tool_rows,
"</tbody></table>",
]

if error_rows:
parts.append('<h2>Tool errors</h2><ul class="errors">')
parts.extend(error_rows)
parts.append("</ul>")

if findings:
parts.append("<h2>Findings</h2>")
parts.append(
"<table><thead><tr><th>Severity</th><th>Tool</th><th>Rule</th>"
"<th>Location</th><th>Message</th></tr></thead><tbody>"
)
parts.extend(finding_rows)
parts.append("</tbody></table>")
else:
parts.append("<p>No findings. Clean run.</p>")

parts.append("</body></html>")
return "\n".join(parts)


def gate_passed(findings: list[Finding], fail_on: str) -> bool:
threshold = SEVERITY_ORDER[fail_on]
return not any(f.severity_rank >= threshold for f in findings)
81 changes: 48 additions & 33 deletions tests/test_aggregator_and_report.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
from unittest.mock import patch

from hypothesis import given, settings
from hypothesis import strategies as st

import stellargate.cli
from stellargate.aggregator import ToolRunResult, all_findings, run_all
from stellargate.config import Config, ToolConfig
from stellargate.report import gate_passed, to_json, to_markdown
from stellargate.schema import SEVERITY_ORDER, Finding
from stellargate.report import gate_passed, to_html, to_json, to_markdown
from stellargate.schema import Finding


def make_results():
Expand Down Expand Up @@ -87,34 +85,51 @@ def test_run_all_survives_an_unexpected_adapter_exception():
# crucially: run_all itself did not raise


SEVERITIES = ["critical", "high", "medium", "low"]
TOOLS = ["rytscan", "schemalock", "vaultsweep", "shieldscan"]
def test_to_html_contains_conclusion_headers_and_finding():
results = make_results()
html = to_html(results, "high", False)
assert "FAILED" in html
assert "Threshold: fail on" in html
assert "Per-tool summary" in html
assert "<th>Severity</th>" in html
assert "AUTH-001" in html
assert "STELLAR-001" in html
assert "schemalock CLI not found" in html


def test_to_html_escapes_special_characters():
results = [
ToolRunResult(
"rytscan",
[Finding("rytscan", "AUTH-002", "low", "a <b>&'quote'</b> message", "x.yaml:1")],
None,
)
]
html = to_html(results, "low", True)
assert "PASSED" in html
assert "<b>" not in html
assert "&lt;b&gt;" in html
assert "&amp;" in html
assert "</script>" not in html


@given(
st.lists(
st.builds(
Finding,
tool=st.sampled_from(TOOLS),
rule_id=st.text(min_size=1),
severity=st.sampled_from(SEVERITIES),
message=st.text(),
def test_cli_html_report_writes_file(tmp_path):
cfg = Config(target=".", fail_on="high", tools={"rytscan": ToolConfig(enabled=True, options={})})
results = [
ToolRunResult(
"rytscan",
[Finding("rytscan", "AUTH-001", "high", "no auth check", "vault.rs:42")],
None,
)
)
)
@settings(max_examples=100)
def test_all_findings_sorts_strictly_by_severity_rank_desc(findings):
"""Property test: all_findings() must sort strictly by severity_rank
descending regardless of input order, mix, or duplicates."""
results = [ToolRunResult(f.tool, [f], None) for f in findings]
if not findings:
assert all_findings(results) == []
return

output = all_findings(results)

assert len(output) == len(findings)
for f in output:
assert f.severity_rank == SEVERITY_ORDER[f.severity]
ranks = [f.severity_rank for f in output]
assert ranks == sorted(ranks, reverse=True)
]
out = tmp_path / "report.html"
with (
patch("stellargate.cli.Config.load", return_value=cfg),
patch("stellargate.cli.run_all", return_value=results),
):
code = stellargate.cli.main(["run", "--config", "unused.yaml", "--html-report", str(out)])

assert code == 1 # high threshold, one high finding -> gate fails
text = out.read_text()
assert "<!DOCTYPE html>" in text
assert "AUTH-001" in text
Loading