diff --git a/README.md b/README.md index 1ddd742..27b660e 100644 --- a/README.md +++ b/README.md @@ -1018,6 +1018,11 @@ structorium zone clear src/scripts # remove zone override ## ⚙️ CI Integration Playbook +Structorium can publish its architecture findings directly into GitHub Code +Scanning. After a scan, run `structorium sarif --output structorium.sarif`; the +export is bounded, GitHub-compatible, and uses stable fingerprints so line-only +moves do not create duplicate alerts. See the [SARIF evidence guide](docs/SARIF.md). + ### GitHub Actions ```yaml @@ -2172,4 +2177,3 @@ copies or substantial portions of the Software.
- diff --git a/app/cli_support/parser.py b/app/cli_support/parser.py index 2f6b2ec..dda616e 100644 --- a/app/cli_support/parser.py +++ b/app/cli_support/parser.py @@ -3,7 +3,8 @@ from __future__ import annotations import argparse -from importlib.metadata import PackageNotFoundError, version as get_version +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as get_version from app.cli_support.parser_groups import ( _add_config_parser, @@ -17,6 +18,7 @@ _add_next_parser, _add_plan_parser, _add_review_parser, + _add_sarif_parser, _add_scan_parser, _add_show_parser, _add_status_parser, @@ -129,6 +131,7 @@ def create_parser(*, langs: list[str], detector_names: list[str]) -> argparse.Ar _add_detect_parser(sub, detector_names) _add_move_parser(sub) _add_review_parser(sub) + _add_sarif_parser(sub) _add_zone_parser(sub) _add_config_parser(sub) _add_dev_parser(sub) diff --git a/app/cli_support/parser_groups.py b/app/cli_support/parser_groups.py index d1705b5..d415734 100644 --- a/app/cli_support/parser_groups.py +++ b/app/cli_support/parser_groups.py @@ -30,6 +30,7 @@ "_add_next_parser", "_add_plan_parser", "_add_review_parser", + "_add_sarif_parser", "_add_scan_parser", "_add_show_parser", "_add_status_parser", @@ -40,6 +41,18 @@ ] +def _add_sarif_parser(sub) -> None: + p_sarif = sub.add_parser( + "sarif", help="Export persisted findings as GitHub-compatible SARIF 2.1.0" + ) + p_sarif.add_argument("--path", type=str, default=".", help="Repository root for relative artifact URIs") + p_sarif.add_argument("--state", type=str, default=None, help="Path to state file") + p_sarif.add_argument("--output", default="structorium.sarif", help="SARIF output path") + p_sarif.add_argument("--include-resolved", action="store_true", help="Include resolved findings") + p_sarif.add_argument("--include-suppressed", action="store_true", help="Include suppressed findings with SARIF suppression metadata") + p_sarif.add_argument("--max-results", type=int, default=5_000, help="Maximum exported results (default: 5000)") + + def _add_scan_parser(sub) -> None: p_scan = sub.add_parser( "scan", diff --git a/app/commands/registry.py b/app/commands/registry.py index 2e4d8e0..ba4abf3 100644 --- a/app/commands/registry.py +++ b/app/commands/registry.py @@ -23,6 +23,7 @@ def _build_handlers() -> dict[str, CommandHandler]: from app.commands.plan import cmd_plan from app.commands.resolve import cmd_ignore_pattern from app.commands.review import cmd_review + from app.commands.sarif_cmd import cmd_sarif from app.commands.scan import cmd_scan from app.commands.show import cmd_show from app.commands.status_cmd import cmd_status @@ -45,6 +46,7 @@ def _build_handlers() -> dict[str, CommandHandler]: "move": cmd_move, "zone": cmd_zone, "review": cmd_review, + "sarif": cmd_sarif, "config": cmd_config, "dev": cmd_dev, "langs": cmd_langs, diff --git a/app/commands/sarif_cmd.py b/app/commands/sarif_cmd.py new file mode 100644 index 0000000..69608c0 --- /dev/null +++ b/app/commands/sarif_cmd.py @@ -0,0 +1,34 @@ +"""CLI command for exporting persisted findings as SARIF 2.1.0.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +from app.commands.helpers.runtime import command_runtime +from core.output_api import colorize +from core.sarif import build_sarif, write_sarif + + +def cmd_sarif(args: argparse.Namespace) -> None: + """Export the current Structorium finding state for CI code-scanning tools.""" + runtime = command_runtime(args) + if not runtime.state.get("last_scan"): + print(colorize("No completed scan found. Run `structorium scan` first.", "red"), file=sys.stderr) + raise SystemExit(2) + + project_root = Path(getattr(args, "path", None) or ".").resolve() + document = build_sarif( + runtime.state, + project_root=project_root, + include_resolved=bool(getattr(args, "include_resolved", False)), + include_suppressed=bool(getattr(args, "include_suppressed", False)), + max_results=int(getattr(args, "max_results", 5_000)), + ) + output = write_sarif(document, Path(args.output)) + result_count = len(document["runs"][0]["results"]) + print(colorize(f"Wrote {result_count} findings to {output}", "green")) + + +__all__ = ["cmd_sarif"] diff --git a/core/sarif.py b/core/sarif.py new file mode 100644 index 0000000..49f8dbf --- /dev/null +++ b/core/sarif.py @@ -0,0 +1,208 @@ +"""Deterministic SARIF 2.1.0 export for persisted Structorium findings.""" + +from __future__ import annotations + +import hashlib +import json +from collections.abc import Mapping +from pathlib import Path, PurePosixPath +from typing import Any + +from core.discovery_api import safe_write_text + +SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json" +SARIF_VERSION = "2.1.0" + +_CONFIDENCE_ORDER = {"high": 0, "medium": 1, "low": 2} + + +def _positive_int(value: object) -> int | None: + if isinstance(value, bool): + return None + try: + parsed = int(value) # type: ignore[arg-type] + except (TypeError, ValueError): + return None + return parsed if parsed > 0 else None + + +def _finding_line(detail: object) -> int: + """Resolve a best-effort source line while remaining tolerant of detector shapes.""" + if not isinstance(detail, Mapping): + return 1 + for key in ("line", "line_number", "start_line", "lineno"): + parsed = _positive_int(detail.get(key)) + if parsed is not None: + return parsed + lines = detail.get("lines") + if isinstance(lines, (list, tuple)): + parsed_lines = [line for item in lines if (line := _positive_int(item))] + if parsed_lines: + return min(parsed_lines) + return 1 + + +def _artifact_uri(file_value: object, project_root: Path) -> str: + """Return a repository-relative, POSIX SARIF artifact URI when possible.""" + raw = str(file_value or ".").replace("\\", "/") + if raw.startswith("file://"): + raw = raw[7:] + candidate = Path(raw) + if candidate.is_absolute(): + try: + raw = str(candidate.resolve().relative_to(project_root.resolve())) + except (OSError, ValueError): + raw = candidate.name + normalized = PurePosixPath(raw).as_posix().lstrip("./") + return normalized or "." + + +def _fingerprint(finding: Mapping[str, Any], artifact_uri: str) -> str: + stable_id = str(finding.get("id") or "") + detector = str(finding.get("detector") or "unknown") + identity = f"{detector}\0{artifact_uri}\0{stable_id}" + return hashlib.sha256(identity.encode("utf-8", errors="replace")).hexdigest() + + +def _level(tier: object) -> str: + parsed = _positive_int(tier) or 4 + if parsed <= 1: + return "error" + if parsed == 2: + return "warning" + return "note" + + +def _active_findings( + state: Mapping[str, Any], + *, + include_resolved: bool, + include_suppressed: bool, +) -> list[Mapping[str, Any]]: + findings = state.get("findings") + if not isinstance(findings, Mapping): + return [] + selected: list[Mapping[str, Any]] = [] + for value in findings.values(): + if not isinstance(value, Mapping): + continue + if not include_resolved and value.get("status") != "open": + continue + if not include_suppressed and bool(value.get("suppressed")): + continue + selected.append(value) + return sorted( + selected, + key=lambda item: ( + _positive_int(item.get("tier")) or 99, + _CONFIDENCE_ORDER.get(str(item.get("confidence", "")).lower(), 3), + str(item.get("id") or ""), + ), + ) + + +def build_sarif( + state: Mapping[str, Any], + *, + project_root: Path | None = None, + include_resolved: bool = False, + include_suppressed: bool = False, + max_results: int = 5_000, +) -> dict[str, Any]: + """Build a GitHub-compatible SARIF document from Structorium state. + + The export is deliberately bounded and deterministic. Stable fingerprints use + Structorium finding identities rather than source line numbers, so line-only + movement does not create duplicate alerts. + """ + project_root = project_root or Path.cwd() + if max_results < 1: + raise ValueError("max_results must be at least 1") + findings = _active_findings( + state, + include_resolved=include_resolved, + include_suppressed=include_suppressed, + )[:max_results] + + detector_names = sorted( + {str(finding.get("detector") or "unknown") for finding in findings} + ) + rules = [ + { + "id": detector, + "name": detector.replace("_", " ").title(), + "shortDescription": {"text": f"Structorium {detector} finding"}, + "helpUri": "https://github.com/DivyamTalwar/Structorium#-detector-registry--complete-reference", + "properties": {"tags": ["maintainability", "architecture"]}, + } + for detector in detector_names + ] + + results: list[dict[str, Any]] = [] + for finding in findings: + detector = str(finding.get("detector") or "unknown") + artifact_uri = _artifact_uri(finding.get("file"), project_root) + result: dict[str, Any] = { + "ruleId": detector, + "level": _level(finding.get("tier")), + "message": {"text": str(finding.get("summary") or finding.get("id") or detector)}, + "locations": [ + { + "physicalLocation": { + "artifactLocation": {"uri": artifact_uri, "uriBaseId": "%SRCROOT%"}, + "region": {"startLine": _finding_line(finding.get("detail"))}, + } + } + ], + "partialFingerprints": { + "structoriumFinding/v1": _fingerprint(finding, artifact_uri) + }, + "properties": { + "structoriumFindingId": str(finding.get("id") or ""), + "tier": _positive_int(finding.get("tier")) or 4, + "confidence": str(finding.get("confidence") or "unknown"), + "status": str(finding.get("status") or "unknown"), + }, + } + if finding.get("suppressed"): + result["suppressions"] = [ + { + "kind": "external", + "justification": str( + finding.get("suppression_pattern") or "Suppressed in Structorium state" + ), + } + ] + results.append(result) + + return { + "$schema": SARIF_SCHEMA, + "version": SARIF_VERSION, + "runs": [ + { + "tool": { + "driver": { + "name": "Structorium", + "informationUri": "https://github.com/DivyamTalwar/Structorium", + "rules": rules, + } + }, + "automationDetails": {"id": "structorium/default"}, + "originalUriBaseIds": { + "%SRCROOT%": {"uri": project_root.resolve().as_uri() + "/"} + }, + "results": results, + } + ], + } + + +def write_sarif(document: Mapping[str, Any], output: Path) -> Path: + """Write a SARIF document atomically and return its resolved output path.""" + output = output.expanduser() + output.parent.mkdir(parents=True, exist_ok=True) + safe_write_text(output, json.dumps(document, indent=2, sort_keys=True) + "\n") + return output.resolve() + + +__all__ = ["SARIF_SCHEMA", "SARIF_VERSION", "build_sarif", "write_sarif"] diff --git a/docs/SARIF.md b/docs/SARIF.md new file mode 100644 index 0000000..1304bb6 --- /dev/null +++ b/docs/SARIF.md @@ -0,0 +1,38 @@ +# GitHub-native SARIF evidence + +Structorium can export persisted findings as SARIF 2.1.0 without rerunning detectors: + +```bash +structorium scan --path . --profile ci +structorium sarif --output artifacts/structorium.sarif +``` + +The exporter is deterministic, caps output at 5,000 findings by default, maps +Structorium tiers to SARIF levels, and emits a stable fingerprint derived from the +finding identity and repository-relative path. A source line moving by itself does +not create a new alert identity. + +Use `--include-resolved` or `--include-suppressed` only for audit exports. Normal CI +exports contain active, unsuppressed findings. `--max-results` can lower the cap for +repositories with strict artifact budgets. + +Upload the result with GitHub's CodeQL action: + +```yaml +- name: Export Structorium SARIF + run: structorium sarif --output artifacts/structorium.sarif + +- name: Upload Structorium SARIF + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: artifacts/structorium.sarif + category: structorium +``` + +The implementation follows GitHub's supported SARIF subset and size guidance. It +does not copy or depend on another analyzer's exporter. + +## Research provenance + +- [GitHub SARIF support](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/sarif-support-for-code-scanning) +- [Uploading SARIF to GitHub](https://docs.github.com/en/code-security/code-scanning/integrating-with-code-scanning/uploading-a-sarif-file-to-github) diff --git a/tests/commands/test_sarif_cmd.py b/tests/commands/test_sarif_cmd.py new file mode 100644 index 0000000..d0a653c --- /dev/null +++ b/tests/commands/test_sarif_cmd.py @@ -0,0 +1,36 @@ +"""CLI surface tests for SARIF export.""" + +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path + +import pytest + +from app.commands.helpers.runtime import CommandRuntime +from app.commands.sarif_cmd import cmd_sarif +from cli import create_parser + + +def test_parser_accepts_sarif_options() -> None: + args = create_parser().parse_args( + ["sarif", "--output", "out.sarif", "--include-suppressed", "--max-results", "99"] + ) + assert args.command == "sarif" + assert args.output == "out.sarif" + assert args.include_suppressed is True + assert args.max_results == 99 + + +def test_command_requires_completed_scan(tmp_path: Path) -> None: + args = Namespace( + output=str(tmp_path / "out.sarif"), + path=str(tmp_path), + include_resolved=False, + include_suppressed=False, + max_results=5_000, + runtime=CommandRuntime(config={}, state={"last_scan": None}, state_path=None), + ) + with pytest.raises(SystemExit) as exc: + cmd_sarif(args) + assert exc.value.code == 2 diff --git a/tests/core/test_sarif.py b/tests/core/test_sarif.py new file mode 100644 index 0000000..7bd5202 --- /dev/null +++ b/tests/core/test_sarif.py @@ -0,0 +1,58 @@ +"""Contract tests for deterministic, GitHub-compatible SARIF export.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from core.sarif import SARIF_VERSION, build_sarif, write_sarif + + +def _finding(fid: str, *, tier: int = 2, line: int = 8, status: str = "open") -> dict: + return { + "id": fid, + "detector": "cycles", + "file": "src/domain.py", + "tier": tier, + "confidence": "high", + "summary": "Dependency cycle", + "detail": {"line": line}, + "status": status, + } + + +def test_build_sarif_has_stable_fingerprint_across_line_moves(tmp_path: Path) -> None: + first = build_sarif({"findings": {"one": _finding("cycles::src/domain.py::a", line=8)}}, project_root=tmp_path) + second = build_sarif({"findings": {"one": _finding("cycles::src/domain.py::a", line=80)}}, project_root=tmp_path) + + first_result = first["runs"][0]["results"][0] + second_result = second["runs"][0]["results"][0] + assert first["version"] == SARIF_VERSION + assert first_result["partialFingerprints"] == second_result["partialFingerprints"] + assert first_result["locations"][0]["physicalLocation"]["region"] == {"startLine": 8} + + +def test_build_sarif_filters_and_bounds_results(tmp_path: Path) -> None: + state = { + "findings": { + "low": _finding("cycles::src/domain.py::low", tier=4), + "high": _finding("cycles::src/domain.py::high", tier=1), + "fixed": _finding("cycles::src/domain.py::fixed", status="fixed"), + "hidden": {**_finding("cycles::src/domain.py::hidden"), "suppressed": True}, + } + } + document = build_sarif(state, project_root=tmp_path, max_results=1) + results = document["runs"][0]["results"] + + assert len(results) == 1 + assert results[0]["properties"]["structoriumFindingId"].endswith("::high") + assert results[0]["level"] == "error" + + +def test_write_sarif_emits_round_trippable_json(tmp_path: Path) -> None: + output = tmp_path / "artifacts" / "structorium.sarif" + document = build_sarif({"findings": {}}, project_root=tmp_path) + resolved = write_sarif(document, output) + + assert resolved == output.resolve() + assert json.loads(output.read_text(encoding="utf-8")) == document diff --git a/tests/review/test_review_commands.py b/tests/review/test_review_commands.py index 823fcfe..1d980c1 100644 --- a/tests/review/test_review_commands.py +++ b/tests/review/test_review_commands.py @@ -775,9 +775,9 @@ def test_do_run_batches_dry_run_generates_packet_and_prompts( assert len(packet_files) == 1 blind_packet = tmp_path / ".structorium" / "review_packet_blind.json" assert blind_packet.exists() - prompt_files = list(runs_dir.glob("*/prompts/batch-*.md")) + prompt_files = sorted(runs_dir.glob("*/prompts/batch-*.md")) assert len(prompt_files) == 2 - prompt_text = prompt_files[0].read_text() + prompt_text = "\n".join(path.read_text() for path in prompt_files) assert "Blind packet:" in prompt_text assert str(blind_packet) in prompt_text assert "Previously flagged issues" in prompt_text