diff --git a/stellargate/cli.py b/stellargate/cli.py index ca49bfe..8691810 100644 --- a/stellargate/cli.py +++ b/stellargate/cli.py @@ -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_json, to_markdown, to_sarif logger = logging.getLogger("stellargate") @@ -56,6 +56,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("--sarif-report", default=None, help="Write SARIF 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") @@ -113,6 +114,10 @@ def _run(args: argparse.Namespace) -> int: with open(args.json_report, "w") as f: json.dump(to_json(results, fail_on, passed), f, indent=2) + if args.sarif_report: + with open(args.sarif_report, "w") as f: + json.dump(to_sarif(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)) diff --git a/stellargate/report.py b/stellargate/report.py index e32127a..19036e4 100644 --- a/stellargate/report.py +++ b/stellargate/report.py @@ -30,6 +30,66 @@ def to_json(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> di } +SEVERITY_TO_LEVEL = { + "critical": "error", + "high": "error", + "medium": "warning", + "low": "note", +} + + +def to_sarif(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> dict: + findings = [f for r in results for f in r.findings] + + rules = {} + for f in findings: + if f.rule_id in rules: + continue + rules[f.rule_id] = { + "id": f.rule_id, + "name": f.rule_id, + "shortDescription": { + "text": f"{f.tool}: {f.message}", + }, + } + + sarif_results = [] + for f in findings: + result = { + "ruleId": f.rule_id, + "level": SEVERITY_TO_LEVEL[f.severity], + "message": {"text": f.message}, + } + if f.location: + result["locations"] = [ + { + "physicalLocation": { + "artifactLocation": {"uri": f.location}, + } + } + ] + sarif_results.append(result) + + run = { + "tool": { + "driver": { + "name": "stellargate", + "version": "0.1.0", + "informationUri": "https://github.com/BreachDirect/stellargate", + } + }, + "results": sarif_results, + } + if rules: + run["tool"]["driver"]["rules"] = list(rules.values()) + + return { + "$schema": "https://json.schemastore.org/sarif-2.1.0.json", + "version": "2.1.0", + "runs": [run], + } + + def to_markdown(results: list[ToolRunResult], fail_on: str, gate_passed: bool) -> str: findings = [f for r in results for f in r.findings] lines: list[str] = [] diff --git a/tests/test_aggregator_and_report.py b/tests/test_aggregator_and_report.py index 46c194b..1abb1e4 100644 --- a/tests/test_aggregator_and_report.py +++ b/tests/test_aggregator_and_report.py @@ -1,9 +1,10 @@ -from unittest.mock import patch +from unittest.mock import mock_open, patch from hypothesis import given, settings from hypothesis import strategies as st from stellargate.aggregator import ToolRunResult, all_findings, run_all +from stellargate.cli import _run from stellargate.config import Config, ToolConfig from stellargate.report import gate_passed, to_json, to_markdown from stellargate.schema import SEVERITY_ORDER, Finding @@ -63,6 +64,86 @@ def test_to_markdown_shows_failed_status_and_tool_error(): assert "STELLAR-001" in md +def test_to_sarif_has_schema_version_and_runs(): + results = make_results() + sarif = to_sarif(results, "high", False) + assert sarif["$schema"] == "https://json.schemastore.org/sarif-2.1.0.json" + assert sarif["version"] == "2.1.0" + assert isinstance(sarif["runs"], list) and len(sarif["runs"]) == 1 + driver = sarif["runs"][0]["tool"]["driver"] + assert driver["name"] == "stellargate" + assert driver["version"] == "0.1.0" + + +def test_to_sarif_severity_level_mapping(): + findings = [ + Finding("t", "R1", "critical", "m"), + Finding("t", "R2", "high", "m"), + Finding("t", "R3", "medium", "m"), + Finding("t", "R4", "low", "m"), + ] + sarif = to_sarif( + [ToolRunResult("t", findings, None)], "high", False + ) + levels = [ + SEVERITY_TO_LEVEL[f.severity] + for f in findings + ] + assert levels == ["error", "error", "warning", "note"] + result_levels = [r["level"] for r in sarif["runs"][0]["results"]] + assert result_levels == levels + + +def test_to_sarif_creates_one_result_per_finding_with_ruleid_and_location(): + results = make_results() + sarif = to_sarif(results, "high", False) + sarif_results = sarif["runs"][0]["results"] + assert len(sarif_results) == 2 + assert [r["ruleId"] for r in sarif_results] == ["AUTH-001", "STELLAR-001"] + auth = [r for r in sarif_results if r["ruleId"] == "AUTH-001"][0] + uri = auth["locations"][0]["physicalLocation"]["artifactLocation"]["uri"] + assert uri == "vault.rs:42" + + +def test_to_sarif_empty_findings_yields_empty_results(): + sarif = to_sarif([ToolRunResult("t", [], None)], "high", True) + assert sarif["runs"][0]["results"] == [] + assert "rules" not in sarif["runs"][0]["tool"]["driver"] + + +def test_cli_writes_sarif_report_file(): + from types import SimpleNamespace + + args = SimpleNamespace( + config="stellargate.example.yaml", + fail_on=None, + json_report=None, + sarif_report="build/report.sarif", + md_report=None, + ) + config = Config( + target=".", + fail_on="high", + tools={}, + ) + with patch("stellargate.cli.Config.load", return_value=config), patch( + "stellargate.cli.run_all", return_value=make_results() + ) as mock_run_all, patch( + "stellargate.cli.gate_passed", return_value=False + ), patch("builtins.open", mock_open()) as mock_file: + _run(args) + mock_run_all.assert_called_once() + handle = mock_file() + written = "".join(call.args[0] for call in handle.write.call_args_list) + import json + + data = json.loads(written) + assert data["$schema"].endswith("sarif-2.1.0.json") + assert data["version"] == "2.1.0" + assert len(data["runs"]) == 1 + assert data["runs"][0]["tool"]["driver"]["name"] == "stellargate" + + 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