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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2172,4 +2177,3 @@ copies or substantial portions of the Software.
<p align="center">
<a href="#structorium">↑ Back to top</a>
</p>

5 changes: 4 additions & 1 deletion app/cli_support/parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions app/cli_support/parser_groups.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions app/commands/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions app/commands/sarif_cmd.py
Original file line number Diff line number Diff line change
@@ -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"]
208 changes: 208 additions & 0 deletions core/sarif.py
Original file line number Diff line number Diff line change
@@ -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"]
38 changes: 38 additions & 0 deletions docs/SARIF.md
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading