appsec-rules-pack 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ """AppSec rules pack validator package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.2.0"
@@ -0,0 +1,5 @@
1
+ """Module entry point for the AppSec rules pack CLI."""
2
+
3
+ from appsec_rules_pack.cli import app
4
+
5
+ app()
@@ -0,0 +1,384 @@
1
+ """Typer command-line interface for AppSec rules pack validation."""
2
+
3
+ import json
4
+ from enum import StrEnum
5
+ from pathlib import Path
6
+ from typing import Annotated
7
+
8
+ import typer
9
+ import yaml
10
+
11
+ from appsec_rules_pack import __version__
12
+ from appsec_rules_pack.exporter import build_index_from_files
13
+ from appsec_rules_pack.reporter import build_coverage_from_files
14
+ from appsec_rules_pack.sarif_export import build_sarif_from_files
15
+ from appsec_rules_pack.semgrep_scaffold import (
16
+ PATTERN_PLACEHOLDER,
17
+ build_semgrep_scaffold_from_files,
18
+ )
19
+ from appsec_rules_pack.validator import (
20
+ ValidationIssue,
21
+ ValidationResult,
22
+ validate_rules_file,
23
+ validate_rules_files,
24
+ )
25
+
26
+ app = typer.Typer(help="Validate AppSec rules pack files.")
27
+ export_app = typer.Typer(help="Derive machine-readable artifacts from rules packs.")
28
+ app.add_typer(export_app, name="export")
29
+ report_app = typer.Typer(help="Derive coverage and summary reports from rules packs.")
30
+ app.add_typer(report_app, name="report")
31
+
32
+
33
+ class OutputFormat(StrEnum):
34
+ """Supported validation output formats."""
35
+
36
+ text = "text"
37
+ json = "json"
38
+
39
+
40
+ RulesPathArg = Annotated[
41
+ Path,
42
+ typer.Argument(exists=True, file_okay=True, dir_okay=True),
43
+ ]
44
+ FailOnWarningsOpt = Annotated[
45
+ bool,
46
+ typer.Option(
47
+ "--fail-on-warnings",
48
+ help="Return a non-zero exit code when warnings are present.",
49
+ ),
50
+ ]
51
+ RequireExamplesOpt = Annotated[
52
+ bool,
53
+ typer.Option(
54
+ "--require-examples",
55
+ help="Warn when an enabled rule has no compliant and violating examples.",
56
+ ),
57
+ ]
58
+ FormatOpt = Annotated[
59
+ OutputFormat,
60
+ typer.Option(
61
+ "--format",
62
+ "-f",
63
+ help="Output format: text (default) or json.",
64
+ ),
65
+ ]
66
+ RULE_FILE_SUFFIXES = frozenset((".yaml", ".yml"))
67
+
68
+ SEMGREP_HEADER = (
69
+ "# Reference Semgrep scaffold derived from the AppSec Rules Pack (derivation only).\n"
70
+ "# NOT a runnable ruleset: the source rules are engine-agnostic review rules with no\n"
71
+ "# detection patterns (see ADR-0001). Replace each rule's placeholder pattern-regex\n"
72
+ f"# ('{PATTERN_PLACEHOLDER}') with a real detection before use. Only enabled rules are\n"
73
+ "# emitted. Regenerate: appsec-rules export semgrep <rules> -o <out>.semgrep.yaml\n"
74
+ )
75
+
76
+
77
+ class IndexFormat(StrEnum):
78
+ """Supported export index formats."""
79
+
80
+ json = "json"
81
+
82
+
83
+ IndexFormatOpt = Annotated[
84
+ IndexFormat,
85
+ typer.Option("--format", "-f", help="Index output format (json)."),
86
+ ]
87
+ IndexOutputOpt = Annotated[
88
+ Path | None,
89
+ typer.Option("--output", "-o", help="Write the index to this file instead of stdout."),
90
+ ]
91
+
92
+
93
+ def _issue_path_str(issue: ValidationIssue) -> str:
94
+ return ".".join(str(part) for part in issue.path) if issue.path else "<root>"
95
+
96
+
97
+ def _format_issue(issue: ValidationIssue) -> str:
98
+ return f"{issue.level.upper()} {_issue_path_str(issue)}: {issue.message}"
99
+
100
+
101
+ def _display_path(base_path: Path, file_path: Path) -> str:
102
+ if base_path.is_file():
103
+ return file_path.name
104
+ try:
105
+ return str(file_path.relative_to(base_path))
106
+ except ValueError:
107
+ return str(file_path)
108
+
109
+
110
+ def _format_file_issue(base_path: Path, file_path: Path, issue: ValidationIssue) -> str:
111
+ return f"{_display_path(base_path, file_path)}: {_format_issue(issue)}"
112
+
113
+
114
+ def _iter_rule_files(path: Path) -> tuple[Path, ...]:
115
+ if path.is_file():
116
+ return (path,)
117
+
118
+ return tuple(
119
+ sorted(
120
+ (
121
+ file_path
122
+ for file_path in path.rglob("*")
123
+ if file_path.is_file() and file_path.suffix.lower() in RULE_FILE_SUFFIXES
124
+ ),
125
+ key=lambda file_path: str(file_path).lower(),
126
+ )
127
+ )
128
+
129
+
130
+ def _plural(count: int, singular: str, plural: str) -> str:
131
+ noun = singular if count == 1 else plural
132
+ return f"{count} {noun}"
133
+
134
+
135
+ def _summarize(results: tuple[ValidationResult, ...]) -> tuple[int, int, int]:
136
+ rule_count = sum(result.rule_count for result in results)
137
+ error_count = sum(result.error_count for result in results)
138
+ warning_count = sum(result.warning_count for result in results)
139
+ return rule_count, error_count, warning_count
140
+
141
+
142
+ def _version_callback(value: bool) -> None:
143
+ if value:
144
+ typer.echo(f"appsec-rules {__version__}")
145
+ raise typer.Exit()
146
+
147
+
148
+ VersionOpt = Annotated[
149
+ bool,
150
+ typer.Option(
151
+ "--version",
152
+ callback=_version_callback,
153
+ is_eager=True,
154
+ help="Show the installed version and exit.",
155
+ ),
156
+ ]
157
+
158
+
159
+ @app.callback()
160
+ def main(version: VersionOpt = False) -> None:
161
+ """Run AppSec rules pack commands."""
162
+
163
+
164
+ def _build_report(
165
+ rules_path: Path,
166
+ file_results: tuple[tuple[Path, ValidationResult], ...],
167
+ ) -> dict:
168
+ rule_count, error_count, warning_count = _summarize(tuple(result for _, result in file_results))
169
+ files = [
170
+ {
171
+ "path": _display_path(rules_path, rule_file),
172
+ "rules": result.rule_count,
173
+ "errors": result.error_count,
174
+ "warnings": result.warning_count,
175
+ "issues": [
176
+ {
177
+ "level": issue.level,
178
+ "path": _issue_path_str(issue),
179
+ "message": issue.message,
180
+ }
181
+ for issue in result.issues
182
+ ],
183
+ }
184
+ for rule_file, result in file_results
185
+ ]
186
+ return {
187
+ "summary": {
188
+ "files": len(file_results),
189
+ "rules": rule_count,
190
+ "errors": error_count,
191
+ "warnings": warning_count,
192
+ },
193
+ "files": files,
194
+ }
195
+
196
+
197
+ @app.command()
198
+ def validate(
199
+ rules_path: RulesPathArg,
200
+ fail_on_warnings: FailOnWarningsOpt = False,
201
+ require_examples: RequireExamplesOpt = False,
202
+ output_format: FormatOpt = OutputFormat.text,
203
+ ) -> None:
204
+ """Validate one YAML rules pack file or a directory of YAML rule packs."""
205
+
206
+ rule_files = _iter_rule_files(rules_path)
207
+ if not rule_files:
208
+ if output_format is OutputFormat.json:
209
+ typer.echo(
210
+ json.dumps(
211
+ {
212
+ "summary": {"files": 0, "rules": 0, "errors": 1, "warnings": 0},
213
+ "files": [],
214
+ "error": f"no YAML rule files found in {rules_path}",
215
+ },
216
+ indent=2,
217
+ )
218
+ )
219
+ else:
220
+ typer.echo(f"Validation failed: no YAML rule files found in {rules_path}.")
221
+ raise typer.Exit(code=1)
222
+
223
+ if len(rule_files) == 1:
224
+ file_results = (
225
+ (rule_files[0], validate_rules_file(rule_files[0], require_examples=require_examples)),
226
+ )
227
+ else:
228
+ file_results = validate_rules_files(rule_files, require_examples=require_examples)
229
+
230
+ rule_count, error_count, warning_count = _summarize(tuple(result for _, result in file_results))
231
+ ok = error_count == 0
232
+ passed = ok and not (fail_on_warnings and warning_count)
233
+
234
+ if output_format is OutputFormat.json:
235
+ report = _build_report(rules_path, file_results)
236
+ report["summary"]["ok"] = passed
237
+ typer.echo(json.dumps(report, indent=2))
238
+ if not passed:
239
+ raise typer.Exit(code=1)
240
+ return
241
+
242
+ for rule_file, result in file_results:
243
+ for issue in result.issues:
244
+ typer.echo(_format_file_issue(rules_path, rule_file, issue))
245
+
246
+ file_summary = _plural(len(rule_files), "file", "files")
247
+ verdict = "passed" if passed else "failed"
248
+ typer.echo(
249
+ f"Validation {verdict}: {file_summary}, {rule_count} rules, "
250
+ f"{error_count} errors, {warning_count} warnings."
251
+ )
252
+ if not passed:
253
+ raise typer.Exit(code=1)
254
+
255
+
256
+ @export_app.command("index")
257
+ def export_index(
258
+ rules_path: RulesPathArg,
259
+ output_format: IndexFormatOpt = IndexFormat.json,
260
+ output: IndexOutputOpt = None,
261
+ ) -> None:
262
+ """Derive a machine-readable JSON index from a rules pack file or directory.
263
+
264
+ Derivation only: this reads pack and rule metadata and never executes rules.
265
+ """
266
+
267
+ rule_files = _iter_rule_files(rules_path)
268
+ if not rule_files:
269
+ typer.echo(f"Export failed: no YAML rule files found in {rules_path}.", err=True)
270
+ raise typer.Exit(code=1)
271
+
272
+ index = build_index_from_files(list(rule_files))
273
+ document = json.dumps(index, indent=2) + "\n"
274
+
275
+ if output is not None:
276
+ output.parent.mkdir(parents=True, exist_ok=True)
277
+ output.write_text(document, encoding="utf-8")
278
+ typer.echo(f"Wrote index for {_plural(len(rule_files), 'file', 'files')} to {output}.")
279
+ return
280
+
281
+ typer.echo(document, nl=False)
282
+
283
+
284
+ @export_app.command("semgrep")
285
+ def export_semgrep(
286
+ rules_path: RulesPathArg,
287
+ output: IndexOutputOpt = None,
288
+ ) -> None:
289
+ """Derive a NON-RUNNABLE reference Semgrep scaffold (derivation only).
290
+
291
+ The scaffold carries rule metadata but placeholder patterns; it is not a working
292
+ ruleset and must have real detections added before use (see ADR-0001).
293
+ """
294
+
295
+ rule_files = _iter_rule_files(rules_path)
296
+ if not rule_files:
297
+ typer.echo(f"Export failed: no YAML rule files found in {rules_path}.", err=True)
298
+ raise typer.Exit(code=1)
299
+
300
+ scaffold = build_semgrep_scaffold_from_files(list(rule_files))
301
+ body = yaml.safe_dump(scaffold, sort_keys=False, allow_unicode=True)
302
+ document = SEMGREP_HEADER + body
303
+
304
+ if output is not None:
305
+ output.parent.mkdir(parents=True, exist_ok=True)
306
+ output.write_text(document, encoding="utf-8")
307
+ typer.echo(
308
+ f"Wrote Semgrep scaffold for {_plural(len(rule_files), 'file', 'files')} to {output}."
309
+ )
310
+ return
311
+
312
+ typer.echo(document, nl=False)
313
+
314
+
315
+ @export_app.command("sarif")
316
+ def export_sarif(
317
+ rules_path: RulesPathArg,
318
+ output: IndexOutputOpt = None,
319
+ ) -> None:
320
+ """Derive a SARIF 2.1.0 rule-catalog (reportingDescriptors, no results).
321
+
322
+ The pack does not execute, so the SARIF ``results`` array is intentionally empty;
323
+ this publishes the rule catalog and metadata for SARIF-aware tools (see ADR-0001).
324
+ """
325
+
326
+ rule_files = _iter_rule_files(rules_path)
327
+ if not rule_files:
328
+ typer.echo(f"Export failed: no YAML rule files found in {rules_path}.", err=True)
329
+ raise typer.Exit(code=1)
330
+
331
+ sarif = build_sarif_from_files(list(rule_files))
332
+ document = json.dumps(sarif, indent=2) + "\n"
333
+
334
+ if output is not None:
335
+ output.parent.mkdir(parents=True, exist_ok=True)
336
+ output.write_text(document, encoding="utf-8")
337
+ typer.echo(
338
+ f"Wrote SARIF rule-catalog for {_plural(len(rule_files), 'file', 'files')} to {output}."
339
+ )
340
+ return
341
+
342
+ typer.echo(document, nl=False)
343
+
344
+
345
+ @report_app.command("coverage")
346
+ def report_coverage(
347
+ rules_path: RulesPathArg,
348
+ output_format: FormatOpt = OutputFormat.text,
349
+ output: IndexOutputOpt = None,
350
+ ) -> None:
351
+ """Report framework-mapping coverage across a rules pack.
352
+
353
+ Derivation only: this summarizes mapping metadata and never executes rules.
354
+ """
355
+
356
+ rule_files = _iter_rule_files(rules_path)
357
+ if not rule_files:
358
+ typer.echo(f"Report failed: no YAML rule files found in {rules_path}.", err=True)
359
+ raise typer.Exit(code=1)
360
+
361
+ coverage = build_coverage_from_files(list(rule_files))
362
+
363
+ if output_format is OutputFormat.json:
364
+ document = json.dumps(coverage, indent=2) + "\n"
365
+ if output is not None:
366
+ output.parent.mkdir(parents=True, exist_ok=True)
367
+ output.write_text(document, encoding="utf-8")
368
+ typer.echo(f"Wrote coverage report to {output}.")
369
+ return
370
+ typer.echo(document, nl=False)
371
+ return
372
+
373
+ total = coverage["rules"]
374
+ typer.echo(f"Mapping coverage for {_plural(total, 'rule', 'rules')}:")
375
+ for framework, stats in coverage["frameworks"].items():
376
+ covered = stats["covered"]
377
+ pct = round(100 * covered / total) if total else 0
378
+ line = f" {framework:<22} {covered}/{total} ({pct}%)"
379
+ if stats["missing"]:
380
+ line += " missing: " + ", ".join(stats["missing"])
381
+ typer.echo(line)
382
+ if coverage["categories"]:
383
+ cats = ", ".join(f"{name}={count}" for name, count in coverage["categories"].items())
384
+ typer.echo(f"Categories: {cats}")
@@ -0,0 +1,66 @@
1
+ """Derive a machine-readable index from rules pack files.
2
+
3
+ This module only *reads and derives* metadata from a rules pack. It never executes
4
+ rules, scans code, or emits findings/SARIF, so it preserves the engine-agnostic
5
+ boundary of the validator (see ADR-0001).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from appsec_rules_pack.loader import load_yaml_file
14
+
15
+ INDEX_SCHEMA = "appsec-rules-index/v1"
16
+
17
+ # Per-rule fields copied into the index. Derivation only: no rule body, match
18
+ # logic, examples, or evidence snippets are executed or expanded.
19
+ _RULE_FIELDS = (
20
+ "id",
21
+ "title",
22
+ "severity",
23
+ "category",
24
+ "status",
25
+ "enforcement",
26
+ "targets",
27
+ "mappings",
28
+ "deprecation",
29
+ )
30
+ _PACK_FIELDS = ("id", "name", "version")
31
+
32
+
33
+ def _pack_summary(payload: Any) -> dict[str, Any]:
34
+ pack = payload.get("pack") if isinstance(payload, dict) else None
35
+ if not isinstance(pack, dict):
36
+ return {}
37
+ return {field: pack[field] for field in _PACK_FIELDS if field in pack}
38
+
39
+
40
+ def _rule_summary(rule: Any) -> dict[str, Any]:
41
+ if not isinstance(rule, dict):
42
+ return {}
43
+ return {field: rule[field] for field in _RULE_FIELDS if field in rule}
44
+
45
+
46
+ def build_pack_index(payload: Any) -> dict[str, Any]:
47
+ """Build the index entry for a single rules pack payload."""
48
+
49
+ rules = payload.get("rules") if isinstance(payload, dict) else None
50
+ rule_entries = [_rule_summary(rule) for rule in rules] if isinstance(rules, list) else []
51
+ return {"pack": _pack_summary(payload), "rules": rule_entries}
52
+
53
+
54
+ def build_index(payloads: list[Any]) -> dict[str, Any]:
55
+ """Build the full index document from one or more pack payloads."""
56
+
57
+ return {
58
+ "schema": INDEX_SCHEMA,
59
+ "packs": [build_pack_index(payload) for payload in payloads],
60
+ }
61
+
62
+
63
+ def build_index_from_files(paths: list[Path]) -> dict[str, Any]:
64
+ """Load each YAML file and derive the index, preserving the given order."""
65
+
66
+ return build_index([load_yaml_file(path) for path in paths])
@@ -0,0 +1,13 @@
1
+ """File loading helpers for AppSec rules pack validation."""
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ import yaml
7
+
8
+
9
+ def load_yaml_file(path: Path) -> Any:
10
+ """Load a YAML file with safe parsing."""
11
+
12
+ with path.open("r", encoding="utf-8") as handle:
13
+ return yaml.safe_load(handle)
@@ -0,0 +1,80 @@
1
+ """Derive a framework-mapping coverage report from rules pack files.
2
+
3
+ Derivation only: this reads mapping metadata and summarizes coverage. It never
4
+ executes rules, scans code, or emits findings, so it preserves the engine-agnostic
5
+ boundary of the validator (see ADR-0001).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from collections import Counter
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from appsec_rules_pack.loader import load_yaml_file
15
+
16
+ COVERAGE_SCHEMA = "appsec-rules-coverage/v1"
17
+
18
+ # Mapping frameworks reported. owasp_top_10_2025 is optional in the schema, so its
19
+ # coverage is the informative one; the rest are required and should read 100%.
20
+ _FRAMEWORKS = (
21
+ "owasp_asvs",
22
+ "owasp_api_top_10_2023",
23
+ "owasp_top_10_2025",
24
+ "cwe",
25
+ "nist_ssdf",
26
+ )
27
+
28
+
29
+ def _rules(payload: Any) -> list[dict[str, Any]]:
30
+ rules = payload.get("rules") if isinstance(payload, dict) else None
31
+ if not isinstance(rules, list):
32
+ return []
33
+ return [rule for rule in rules if isinstance(rule, dict)]
34
+
35
+
36
+ def _has_mapping(rule: dict[str, Any], framework: str) -> bool:
37
+ mappings = rule.get("mappings")
38
+ if not isinstance(mappings, dict):
39
+ return False
40
+ values = mappings.get(framework)
41
+ return isinstance(values, list) and len(values) > 0
42
+
43
+
44
+ def build_coverage(payloads: list[Any]) -> dict[str, Any]:
45
+ """Build a coverage report across one or more pack payloads."""
46
+
47
+ rules: list[dict[str, Any]] = []
48
+ for payload in payloads:
49
+ rules.extend(_rules(payload))
50
+
51
+ total = len(rules)
52
+ frameworks: dict[str, Any] = {}
53
+ for framework in _FRAMEWORKS:
54
+ missing = [
55
+ rule["id"]
56
+ for rule in rules
57
+ if not _has_mapping(rule, framework) and isinstance(rule.get("id"), str)
58
+ ]
59
+ frameworks[framework] = {
60
+ "covered": total - len(missing),
61
+ "total": total,
62
+ "missing": missing,
63
+ }
64
+
65
+ categories = Counter(
66
+ rule["category"] for rule in rules if isinstance(rule.get("category"), str)
67
+ )
68
+
69
+ return {
70
+ "schema": COVERAGE_SCHEMA,
71
+ "rules": total,
72
+ "frameworks": frameworks,
73
+ "categories": dict(sorted(categories.items())),
74
+ }
75
+
76
+
77
+ def build_coverage_from_files(paths: list[Path]) -> dict[str, Any]:
78
+ """Load each YAML file and derive the coverage report."""
79
+
80
+ return build_coverage([load_yaml_file(path) for path in paths])
@@ -0,0 +1,108 @@
1
+ """Derive a SARIF rule-catalog export from the rules pack.
2
+
3
+ Derivation only: this declares the pack's rules as SARIF reportingDescriptors
4
+ (``tool.driver.rules``) with an EMPTY ``results`` array. The pack does not execute or
5
+ scan code (see ADR-0001), so it produces no findings; this export lets SARIF-aware
6
+ tools ingest the rule catalog and its metadata, not scan results.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from appsec_rules_pack.loader import load_yaml_file
15
+
16
+ SARIF_VERSION = "2.1.0"
17
+ SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json"
18
+ TOOL_NAME = "AppSec Rules Pack"
19
+ INFORMATION_URI = "https://github.com/lucashgrifoni/AppSec-Rules-Pack"
20
+
21
+ # SARIF result levels and GitHub code-scanning security-severity bands.
22
+ _LEVEL_MAP = {"critical": "error", "high": "error", "medium": "warning", "low": "note"}
23
+ _SECURITY_SEVERITY = {"critical": "9.5", "high": "8.0", "medium": "5.0", "low": "2.0"}
24
+
25
+ _PROPERTY_FIELDS = (
26
+ ("cwe", "cwe"),
27
+ ("owasp-asvs", "owasp_asvs"),
28
+ ("owasp-api-top-10-2023", "owasp_api_top_10_2023"),
29
+ ("owasp-top-10-2025", "owasp_top_10_2025"),
30
+ ("nist-ssdf", "nist_ssdf"),
31
+ )
32
+
33
+
34
+ def _descriptor(rule: dict[str, Any]) -> dict[str, Any]:
35
+ mappings = rule.get("mappings") if isinstance(rule.get("mappings"), dict) else {}
36
+ severity = rule.get("severity")
37
+
38
+ tags = ["security"]
39
+ if isinstance(rule.get("category"), str):
40
+ tags.append(rule["category"])
41
+ cwes = mappings.get("cwe")
42
+ if isinstance(cwes, list):
43
+ tags.extend(cwe for cwe in cwes if isinstance(cwe, str))
44
+
45
+ properties: dict[str, Any] = {"tags": tags}
46
+ if severity in _SECURITY_SEVERITY:
47
+ properties["security-severity"] = _SECURITY_SEVERITY[severity]
48
+ for out_key, field in _PROPERTY_FIELDS:
49
+ value = mappings.get(field)
50
+ if isinstance(value, list) and value:
51
+ properties[out_key] = list(value)
52
+
53
+ return {
54
+ "id": rule.get("id"),
55
+ "name": rule.get("id"),
56
+ "shortDescription": {"text": rule.get("title")},
57
+ "fullDescription": {"text": rule.get("description")},
58
+ "helpUri": INFORMATION_URI,
59
+ "defaultConfiguration": {"level": _LEVEL_MAP.get(severity, "warning")},
60
+ "properties": properties,
61
+ }
62
+
63
+
64
+ def _tool_version(payloads: list[Any]) -> str:
65
+ for payload in payloads:
66
+ pack = payload.get("pack") if isinstance(payload, dict) else None
67
+ if isinstance(pack, dict) and isinstance(pack.get("version"), str):
68
+ return pack["version"]
69
+ return "0.0.0"
70
+
71
+
72
+ def build_sarif(payloads: list[Any]) -> dict[str, Any]:
73
+ """Build a SARIF 2.1.0 rule-catalog document (no results) from pack payloads."""
74
+
75
+ descriptors: list[dict[str, Any]] = []
76
+ for payload in payloads:
77
+ rules = payload.get("rules") if isinstance(payload, dict) else None
78
+ if not isinstance(rules, list):
79
+ continue
80
+ descriptors.extend(
81
+ _descriptor(rule)
82
+ for rule in rules
83
+ if isinstance(rule, dict) and rule.get("status") == "enabled"
84
+ )
85
+
86
+ return {
87
+ "$schema": SARIF_SCHEMA,
88
+ "version": SARIF_VERSION,
89
+ "runs": [
90
+ {
91
+ "tool": {
92
+ "driver": {
93
+ "name": TOOL_NAME,
94
+ "informationUri": INFORMATION_URI,
95
+ "version": _tool_version(payloads),
96
+ "rules": descriptors,
97
+ }
98
+ },
99
+ "results": [],
100
+ }
101
+ ],
102
+ }
103
+
104
+
105
+ def build_sarif_from_files(paths: list[Path]) -> dict[str, Any]:
106
+ """Load each YAML file and derive the SARIF rule-catalog document."""
107
+
108
+ return build_sarif([load_yaml_file(path) for path in paths])