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
20 changes: 8 additions & 12 deletions stellargate/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ def main(argv: list[str] | None = None) -> int:
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("--fail-on", default=None, help="Override fail_on threshold from config")
run_parser.add_argument(
"--group-by",
default="severity",
choices=["severity", "tool"],
help="Group findings by severity or by tool (default: severity)",
)

args = parser.parse_args(argv)

Expand Down Expand Up @@ -97,25 +103,15 @@ def _run(args: argparse.Namespace) -> int:
findings = all_findings(results)
passed = gate_passed(findings, fail_on)

for r in results:
if r.error:
logger.warning("Tool %s errored: %s", r.tool, r.error)
else:
logger.info("Tool %s produced %d finding(s)", r.tool, len(r.findings))

logger.info("Gate result: %s (fail_on=%s)", "passed" if passed else "failed", fail_on)

# The Markdown report is the tool's primary output: it must go to stdout
# verbatim so CI/scripts can capture it. Diagnostics use logging (stderr).
print(to_markdown(results, fail_on, passed))
print(to_markdown(results, fail_on, passed, args.group_by))

if args.json_report:
with open(args.json_report, "w") as f:
json.dump(to_json(results, fail_on, passed), f, indent=2)

if args.md_report:
with open(args.md_report, "w") as f:
f.write(to_markdown(results, fail_on, passed))
f.write(to_markdown(results, fail_on, passed, args.group_by))

return 0 if passed else 1

Expand Down
46 changes: 36 additions & 10 deletions stellargate/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,15 @@ def to_json(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> di
}


def to_markdown(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> str:
def to_markdown(
results: list[ToolRunResult],
fail_on: str,
gate_passed: bool,
group_by: str = "severity",
) -> str:
if group_by not in ("severity", "tool"):
raise ValueError(f"Invalid group_by '{group_by}'; must be 'severity' or 'tool'")

findings = [f for r in results for f in r.findings]
lines: list[str] = []

Expand All @@ -55,15 +63,33 @@ def to_markdown(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -
lines.append(f"- **{r.tool}**: {r.error}")
lines.append("")

if findings:
lines.append("## Findings\n")
lines.append("| Severity | Tool | Rule | Location | Message |")
lines.append("|---|---|---|---|---|")
for f in findings:
loc = f.location or "—"
lines.append(
f"| {f.severity.upper()} | {f.tool} | {f.rule_id} | {loc} | {f.message} |"
)
if findings or group_by == "tool":
if group_by == "severity":
lines.append("## Findings\n")
lines.append("| Severity | Tool | Rule | Location | Message |")
lines.append("|---|---|---|---|---|")
for f in findings:
loc = f.location or "—"
lines.append(
f"| {f.severity.upper()} | {f.tool} | {f.rule_id} | {loc} | {f.message} |"
)
else:
lines.append("## Findings\n")
for r in results:
if r.error:
continue
lines.append(f"### {r.tool}\n")
if r.findings:
lines.append("| Severity | Rule | Location | Message |")
lines.append("|---|---|---|---|")
for f in sorted(r.findings, key=lambda x: -x.severity_rank):
loc = f.location or "—"
lines.append(
f"| {f.severity.upper()} | {f.rule_id} | {loc} | {f.message} |"
)
else:
lines.append("No findings. Clean run.")
lines.append("")
else:
lines.append("No findings. Clean run.")

Expand Down
42 changes: 40 additions & 2 deletions tests/test_aggregator_and_report.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from unittest.mock import patch

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

from stellargate.aggregator import ToolRunResult, all_findings, run_all
from stellargate.config import Config, ToolConfig
Expand Down Expand Up @@ -63,6 +62,45 @@ def test_to_markdown_shows_failed_status_and_tool_error():
assert "STELLAR-001" in md


def test_to_markdown_default_grouping_is_severity():
results = make_results()
md_default = to_markdown(results, "high", False)
md_explicit = to_markdown(results, "high", False, group_by="severity")
# explicitly requesting severity must not change the default output
assert md_explicit == md_default
assert "| Severity | Tool | Rule | Location | Message |" in md_default


def test_to_markdown_groups_by_tool():
results = make_results()
md = to_markdown(results, "high", False, group_by="tool")
assert "### rytscan" in md
assert "### vaultsweep" in md
# per-tool tables have no redundant Tool column
assert "| Severity | Tool | Rule | Location | Message |" not in md
assert "AUTH-001" in md
assert "STELLAR-001" in md


def test_to_markdown_tool_group_skips_errored_tools_and_marks_clean():
results = make_results()
# schemalock errored -> not shown as a findings section (it is in Tool errors)
md = to_markdown(results, "high", False, group_by="tool")
assert "### schemalock" not in md
assert "schemalock CLI not found" in md
# a healthy tool with no findings is marked as clean
results.append(ToolRunResult("shieldscan", [], None))
md2 = to_markdown(results, "high", False, group_by="tool")
assert "### shieldscan" in md2
assert "No findings. Clean run." in md2


def test_to_markdown_rejects_unknown_grouping():
results = make_results()
with pytest.raises(ValueError, match="bogus"):
to_markdown(results, "high", False, group_by="bogus")


def test_run_all_survives_an_unexpected_adapter_exception():
"""Regression test: a bug in one adapter (e.g. a bare KeyError, not an
AdapterError) must not crash the whole run — every other tool's findings
Expand Down
Loading