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.
- appsec_rules_pack/__init__.py +5 -0
- appsec_rules_pack/__main__.py +5 -0
- appsec_rules_pack/cli.py +384 -0
- appsec_rules_pack/exporter.py +66 -0
- appsec_rules_pack/loader.py +13 -0
- appsec_rules_pack/reporter.py +80 -0
- appsec_rules_pack/sarif_export.py +108 -0
- appsec_rules_pack/schemas/appsec-rule.schema.json +302 -0
- appsec_rules_pack/semgrep_scaffold.py +81 -0
- appsec_rules_pack/validator.py +566 -0
- appsec_rules_pack-0.2.0.dist-info/METADATA +252 -0
- appsec_rules_pack-0.2.0.dist-info/RECORD +16 -0
- appsec_rules_pack-0.2.0.dist-info/WHEEL +5 -0
- appsec_rules_pack-0.2.0.dist-info/entry_points.txt +2 -0
- appsec_rules_pack-0.2.0.dist-info/licenses/LICENSE +201 -0
- appsec_rules_pack-0.2.0.dist-info/top_level.txt +1 -0
appsec_rules_pack/cli.py
ADDED
|
@@ -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])
|