appsec-rules-pack 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (39) hide show
  1. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/PKG-INFO +122 -14
  2. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/README.md +121 -13
  3. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/pyproject.toml +3 -1
  4. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/__init__.py +1 -1
  5. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/cli.py +32 -8
  6. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/validator.py +11 -7
  7. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack.egg-info/PKG-INFO +122 -14
  8. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack.egg-info/SOURCES.txt +6 -0
  9. appsec_rules_pack-0.3.0/tests/test_cli_export_output.py +69 -0
  10. appsec_rules_pack-0.3.0/tests/test_cli_output_errors.py +60 -0
  11. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_edge_cases.py +44 -0
  12. appsec_rules_pack-0.3.0/tests/test_export_degradation.py +92 -0
  13. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_exporter.py +1 -1
  14. appsec_rules_pack-0.3.0/tests/test_helpers.py +33 -0
  15. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_packaging.py +5 -14
  16. appsec_rules_pack-0.3.0/tests/test_readme_example.py +62 -0
  17. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_reporter.py +19 -0
  18. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_validator.py +50 -0
  19. appsec_rules_pack-0.3.0/tests/test_validator_degradation.py +80 -0
  20. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_validator_paths.py +5 -11
  21. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/LICENSE +0 -0
  22. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/setup.cfg +0 -0
  23. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/__main__.py +0 -0
  24. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/exporter.py +0 -0
  25. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/loader.py +0 -0
  26. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/reporter.py +0 -0
  27. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/sarif_export.py +0 -0
  28. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/schemas/appsec-rule.schema.json +0 -0
  29. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack/semgrep_scaffold.py +0 -0
  30. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack.egg-info/dependency_links.txt +0 -0
  31. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack.egg-info/entry_points.txt +0 -0
  32. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack.egg-info/requires.txt +0 -0
  33. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/src/appsec_rules_pack.egg-info/top_level.txt +0 -0
  34. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_examples.py +0 -0
  35. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_lifecycle_and_mappings.py +0 -0
  36. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_loader.py +0 -0
  37. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_sarif_export.py +0 -0
  38. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_schema.py +0 -0
  39. {appsec_rules_pack-0.2.0 → appsec_rules_pack-0.3.0}/tests/test_semgrep_scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: appsec-rules-pack
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Generic AppSec policy-as-code rules pack and validator.
5
5
  Author: Lucas Henrique Grifoni
6
6
  License-Expression: Apache-2.0
@@ -38,6 +38,7 @@ Dynamic: license-file
38
38
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lucashgrifoni/AppSec-Rules-Pack/badge)](https://scorecard.dev/viewer/?uri=github.com/lucashgrifoni/AppSec-Rules-Pack)
39
39
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
40
40
  [![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg)](pyproject.toml)
41
+ [![PyPI](https://img.shields.io/pypi/v/appsec-rules-pack.svg)](https://pypi.org/project/appsec-rules-pack/)
41
42
 
42
43
  Reusable AppSec policy-as-code rules for secure application review, CI quality gates,
43
44
  and manual evidence collection.
@@ -69,9 +70,11 @@ configuration.
69
70
  enum/type/additionalProperties failures, duplicate rule IDs, cross-file
70
71
  duplicate IDs, exception-window warnings, exception-policy contradictions,
71
72
  malformed framework mapping IDs, and sensitive-value detection.
72
- - A 90% coverage gate plus a hardened CI/CD surface: a build/lint/test workflow,
73
- a security pipeline (Semgrep, CodeQL, Bandit, Trivy, KICS, pip-audit, Gitleaks,
74
- Dependency Review, actionlint), and OpenSSF Scorecard analysis.
73
+ - A 95% coverage gate plus a hardened CI/CD surface: a build/lint/test workflow (with
74
+ separate jobs running the suite on Ubuntu and Windows, and on Python 3.13), a security
75
+ pipeline (Semgrep, CodeQL, Bandit, Trivy, KICS, pip-audit, Gitleaks, Dependency Review,
76
+ actionlint), and OpenSSF Scorecard analysis. The build/lint/test, cross-platform, and
77
+ security jobs are required status checks on `master`.
75
78
  - Contribution guidance for safe rule additions, a code of conduct, and issue/PR
76
79
  templates.
77
80
  - A CI integration template in `examples/`.
@@ -87,6 +90,8 @@ configuration.
87
90
  | | `-- rule_proposal.md
88
91
  | |-- workflows/
89
92
  | | |-- ci.yml
93
+ | | |-- policy-gate.yml
94
+ | | |-- publish-pypi.yml
90
95
  | | |-- scorecard.yml
91
96
  | | `-- security-ci-cd.yml
92
97
  | |-- CODEOWNERS
@@ -94,6 +99,12 @@ configuration.
94
99
  | `-- dependabot.yml
95
100
  |-- examples/
96
101
  | `-- README.md
102
+ |-- exports/
103
+ | |-- appsec-baseline.index.json
104
+ | |-- sarif/
105
+ | | `-- appsec-baseline.sarif.json
106
+ | `-- semgrep/
107
+ | `-- appsec-baseline.semgrep.yaml
97
108
  |-- rules/
98
109
  | `-- appsec-baseline.yaml
99
110
  |-- src/
@@ -101,7 +112,11 @@ configuration.
101
112
  | |-- __init__.py
102
113
  | |-- __main__.py
103
114
  | |-- cli.py
115
+ | |-- exporter.py
104
116
  | |-- loader.py
117
+ | |-- reporter.py
118
+ | |-- sarif_export.py
119
+ | |-- semgrep_scaffold.py
105
120
  | |-- validator.py
106
121
  | `-- schemas/
107
122
  | `-- appsec-rule.schema.json
@@ -112,12 +127,11 @@ configuration.
112
127
  | | |-- fail/
113
128
  | | |-- pass/
114
129
  | | `-- warn/
115
- | |-- test_edge_cases.py
116
- | |-- test_examples.py
117
- | |-- test_loader.py
118
- | |-- test_packaging.py
119
- | |-- test_validator.py
120
- | `-- test_validator_paths.py
130
+ | |-- helpers.py # shared subprocess helper for tests that shell out
131
+ | `-- test_*.py # one module per validated behaviour
132
+ |-- .gitattributes
133
+ |-- .gitignore
134
+ |-- .gitleaks.toml
121
135
  |-- CHANGELOG.md
122
136
  |-- CODE_OF_CONDUCT.md
123
137
  |-- CONTRIBUTING.md
@@ -130,7 +144,31 @@ configuration.
130
144
  `-- pyproject.toml
131
145
  ```
132
146
 
133
- ## Setup
147
+ ## Installation
148
+
149
+ Install the published package from PyPI:
150
+
151
+ ```bash
152
+ pip install appsec-rules-pack
153
+ ```
154
+
155
+ This installs the `appsec-rules` console script. Pin to a reviewed version
156
+ (for example `appsec-rules-pack==0.3.0`) when using it in a CI quality gate.
157
+
158
+ **What the distribution contains:** the validator, the CLI, and the JSON Schema. It does
159
+ not ship a rules pack — the CLI validates whatever path you point it at.
160
+
161
+ To get the baseline pack of 19 rules, either take the version-pinned copy attached to each
162
+ GitHub Release:
163
+
164
+ ```bash
165
+ curl -LO https://github.com/lucashgrifoni/AppSec-Rules-Pack/releases/download/v0.3.0/appsec-baseline.yaml
166
+ ```
167
+
168
+ or copy [`rules/appsec-baseline.yaml`](rules/appsec-baseline.yaml) from this repository.
169
+ You can also write your own pack from the example below.
170
+
171
+ ### From source (development)
134
172
 
135
173
  ```powershell
136
174
  python -m venv .venv
@@ -147,8 +185,78 @@ python -m venv .venv
147
185
  If the dependencies already exist in the active Python environment, the validator can
148
186
  also be run directly with `PYTHONPATH=src`.
149
187
 
188
+ ## Your First Rules Pack
189
+
190
+ A pack is a `pack` block plus one or more `rules`. This is a complete, valid minimal pack —
191
+ copy it, run `appsec-rules validate` on it, then grow it. Every field shown is required; the
192
+ full contract is in
193
+ [`appsec-rule.schema.json`](src/appsec_rules_pack/schemas/appsec-rule.schema.json), and the
194
+ 19 baseline rules in [`rules/appsec-baseline.yaml`](rules/appsec-baseline.yaml) are worked
195
+ examples.
196
+
197
+ <!-- readme-example:minimal-pack (validated by tests/test_readme_example.py) -->
198
+
199
+ ```yaml
200
+ pack:
201
+ id: my-pack
202
+ name: My Rules Pack
203
+ version: 0.1.0
204
+ mode: advisory
205
+ owner: appsec
206
+ description: A minimal rules pack to start from.
207
+
208
+ rules:
209
+ - id: APPSEC-EXAMPLE-001
210
+ title: Require controlled error handling
211
+ description: Verify that invalid user input returns controlled errors without stack traces.
212
+ severity: medium
213
+ category: configuration
214
+ status: enabled
215
+ enforcement: advisory
216
+ targets:
217
+ - api
218
+ mappings:
219
+ owasp_asvs:
220
+ - V14.4
221
+ owasp_api_top_10_2023:
222
+ - API8:2023
223
+ cwe:
224
+ - CWE-209
225
+ nist_ssdf:
226
+ - PW.7
227
+ evidence:
228
+ required:
229
+ - Error handling path returns a documented response shape.
230
+ signals:
231
+ - Negative tests assert controlled error messages for invalid input.
232
+ match:
233
+ type: review
234
+ includes:
235
+ - API handlers that process untrusted input.
236
+ excludes:
237
+ - Local developer-only scripts that are not shipped.
238
+ remediation:
239
+ guidance: Replace raw exception output with a controlled error contract.
240
+ validation:
241
+ - Run negative tests for invalid input and malformed requests.
242
+ exceptions:
243
+ allowed: true
244
+ max_days: 30
245
+ required_fields:
246
+ - owner
247
+ - justification
248
+ - expires_at
249
+ ```
250
+
251
+ Note the shapes that are easy to guess wrong: `evidence` and `match` are objects (not
252
+ lists), `remediation` needs `guidance`, and `required_fields` accepts only `owner`,
253
+ `justification`, `expires_at`, `compensating_control`, and `validation_plan`.
254
+
150
255
  ## Usage
151
256
 
257
+ The examples below use `rules/appsec-baseline.yaml`, which exists in **this repository**.
258
+ If you installed from PyPI, point the CLI at your own rules file or directory instead.
259
+
152
260
  Validate the baseline rules pack:
153
261
 
154
262
  ```bash
@@ -205,9 +313,9 @@ code is non-zero when validation fails, matching the text output.
205
313
 
206
314
  ## Use It In Your CI
207
315
 
208
- `examples/README.md` contains a GitHub Actions template for installing the pack,
209
- validating a rules directory, and failing the build on errors (and optionally
210
- warnings). Pin the template to a reviewed tag or commit before enabling it as a
316
+ `examples/README.md` contains a GitHub Actions template for installing the pack from
317
+ PyPI, validating a rules directory, and failing the build on errors (and optionally
318
+ warnings). Pin the install to a reviewed release version before enabling it as a
211
319
  quality gate.
212
320
 
213
321
  ## Rule Pack Model
@@ -5,6 +5,7 @@
5
5
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/lucashgrifoni/AppSec-Rules-Pack/badge)](https://scorecard.dev/viewer/?uri=github.com/lucashgrifoni/AppSec-Rules-Pack)
6
6
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)
7
7
  [![Python](https://img.shields.io/badge/python-3.12%2B-blue.svg)](pyproject.toml)
8
+ [![PyPI](https://img.shields.io/pypi/v/appsec-rules-pack.svg)](https://pypi.org/project/appsec-rules-pack/)
8
9
 
9
10
  Reusable AppSec policy-as-code rules for secure application review, CI quality gates,
10
11
  and manual evidence collection.
@@ -36,9 +37,11 @@ configuration.
36
37
  enum/type/additionalProperties failures, duplicate rule IDs, cross-file
37
38
  duplicate IDs, exception-window warnings, exception-policy contradictions,
38
39
  malformed framework mapping IDs, and sensitive-value detection.
39
- - A 90% coverage gate plus a hardened CI/CD surface: a build/lint/test workflow,
40
- a security pipeline (Semgrep, CodeQL, Bandit, Trivy, KICS, pip-audit, Gitleaks,
41
- Dependency Review, actionlint), and OpenSSF Scorecard analysis.
40
+ - A 95% coverage gate plus a hardened CI/CD surface: a build/lint/test workflow (with
41
+ separate jobs running the suite on Ubuntu and Windows, and on Python 3.13), a security
42
+ pipeline (Semgrep, CodeQL, Bandit, Trivy, KICS, pip-audit, Gitleaks, Dependency Review,
43
+ actionlint), and OpenSSF Scorecard analysis. The build/lint/test, cross-platform, and
44
+ security jobs are required status checks on `master`.
42
45
  - Contribution guidance for safe rule additions, a code of conduct, and issue/PR
43
46
  templates.
44
47
  - A CI integration template in `examples/`.
@@ -54,6 +57,8 @@ configuration.
54
57
  | | `-- rule_proposal.md
55
58
  | |-- workflows/
56
59
  | | |-- ci.yml
60
+ | | |-- policy-gate.yml
61
+ | | |-- publish-pypi.yml
57
62
  | | |-- scorecard.yml
58
63
  | | `-- security-ci-cd.yml
59
64
  | |-- CODEOWNERS
@@ -61,6 +66,12 @@ configuration.
61
66
  | `-- dependabot.yml
62
67
  |-- examples/
63
68
  | `-- README.md
69
+ |-- exports/
70
+ | |-- appsec-baseline.index.json
71
+ | |-- sarif/
72
+ | | `-- appsec-baseline.sarif.json
73
+ | `-- semgrep/
74
+ | `-- appsec-baseline.semgrep.yaml
64
75
  |-- rules/
65
76
  | `-- appsec-baseline.yaml
66
77
  |-- src/
@@ -68,7 +79,11 @@ configuration.
68
79
  | |-- __init__.py
69
80
  | |-- __main__.py
70
81
  | |-- cli.py
82
+ | |-- exporter.py
71
83
  | |-- loader.py
84
+ | |-- reporter.py
85
+ | |-- sarif_export.py
86
+ | |-- semgrep_scaffold.py
72
87
  | |-- validator.py
73
88
  | `-- schemas/
74
89
  | `-- appsec-rule.schema.json
@@ -79,12 +94,11 @@ configuration.
79
94
  | | |-- fail/
80
95
  | | |-- pass/
81
96
  | | `-- warn/
82
- | |-- test_edge_cases.py
83
- | |-- test_examples.py
84
- | |-- test_loader.py
85
- | |-- test_packaging.py
86
- | |-- test_validator.py
87
- | `-- test_validator_paths.py
97
+ | |-- helpers.py # shared subprocess helper for tests that shell out
98
+ | `-- test_*.py # one module per validated behaviour
99
+ |-- .gitattributes
100
+ |-- .gitignore
101
+ |-- .gitleaks.toml
88
102
  |-- CHANGELOG.md
89
103
  |-- CODE_OF_CONDUCT.md
90
104
  |-- CONTRIBUTING.md
@@ -97,7 +111,31 @@ configuration.
97
111
  `-- pyproject.toml
98
112
  ```
99
113
 
100
- ## Setup
114
+ ## Installation
115
+
116
+ Install the published package from PyPI:
117
+
118
+ ```bash
119
+ pip install appsec-rules-pack
120
+ ```
121
+
122
+ This installs the `appsec-rules` console script. Pin to a reviewed version
123
+ (for example `appsec-rules-pack==0.3.0`) when using it in a CI quality gate.
124
+
125
+ **What the distribution contains:** the validator, the CLI, and the JSON Schema. It does
126
+ not ship a rules pack — the CLI validates whatever path you point it at.
127
+
128
+ To get the baseline pack of 19 rules, either take the version-pinned copy attached to each
129
+ GitHub Release:
130
+
131
+ ```bash
132
+ curl -LO https://github.com/lucashgrifoni/AppSec-Rules-Pack/releases/download/v0.3.0/appsec-baseline.yaml
133
+ ```
134
+
135
+ or copy [`rules/appsec-baseline.yaml`](rules/appsec-baseline.yaml) from this repository.
136
+ You can also write your own pack from the example below.
137
+
138
+ ### From source (development)
101
139
 
102
140
  ```powershell
103
141
  python -m venv .venv
@@ -114,8 +152,78 @@ python -m venv .venv
114
152
  If the dependencies already exist in the active Python environment, the validator can
115
153
  also be run directly with `PYTHONPATH=src`.
116
154
 
155
+ ## Your First Rules Pack
156
+
157
+ A pack is a `pack` block plus one or more `rules`. This is a complete, valid minimal pack —
158
+ copy it, run `appsec-rules validate` on it, then grow it. Every field shown is required; the
159
+ full contract is in
160
+ [`appsec-rule.schema.json`](src/appsec_rules_pack/schemas/appsec-rule.schema.json), and the
161
+ 19 baseline rules in [`rules/appsec-baseline.yaml`](rules/appsec-baseline.yaml) are worked
162
+ examples.
163
+
164
+ <!-- readme-example:minimal-pack (validated by tests/test_readme_example.py) -->
165
+
166
+ ```yaml
167
+ pack:
168
+ id: my-pack
169
+ name: My Rules Pack
170
+ version: 0.1.0
171
+ mode: advisory
172
+ owner: appsec
173
+ description: A minimal rules pack to start from.
174
+
175
+ rules:
176
+ - id: APPSEC-EXAMPLE-001
177
+ title: Require controlled error handling
178
+ description: Verify that invalid user input returns controlled errors without stack traces.
179
+ severity: medium
180
+ category: configuration
181
+ status: enabled
182
+ enforcement: advisory
183
+ targets:
184
+ - api
185
+ mappings:
186
+ owasp_asvs:
187
+ - V14.4
188
+ owasp_api_top_10_2023:
189
+ - API8:2023
190
+ cwe:
191
+ - CWE-209
192
+ nist_ssdf:
193
+ - PW.7
194
+ evidence:
195
+ required:
196
+ - Error handling path returns a documented response shape.
197
+ signals:
198
+ - Negative tests assert controlled error messages for invalid input.
199
+ match:
200
+ type: review
201
+ includes:
202
+ - API handlers that process untrusted input.
203
+ excludes:
204
+ - Local developer-only scripts that are not shipped.
205
+ remediation:
206
+ guidance: Replace raw exception output with a controlled error contract.
207
+ validation:
208
+ - Run negative tests for invalid input and malformed requests.
209
+ exceptions:
210
+ allowed: true
211
+ max_days: 30
212
+ required_fields:
213
+ - owner
214
+ - justification
215
+ - expires_at
216
+ ```
217
+
218
+ Note the shapes that are easy to guess wrong: `evidence` and `match` are objects (not
219
+ lists), `remediation` needs `guidance`, and `required_fields` accepts only `owner`,
220
+ `justification`, `expires_at`, `compensating_control`, and `validation_plan`.
221
+
117
222
  ## Usage
118
223
 
224
+ The examples below use `rules/appsec-baseline.yaml`, which exists in **this repository**.
225
+ If you installed from PyPI, point the CLI at your own rules file or directory instead.
226
+
119
227
  Validate the baseline rules pack:
120
228
 
121
229
  ```bash
@@ -172,9 +280,9 @@ code is non-zero when validation fails, matching the text output.
172
280
 
173
281
  ## Use It In Your CI
174
282
 
175
- `examples/README.md` contains a GitHub Actions template for installing the pack,
176
- validating a rules directory, and failing the build on errors (and optionally
177
- warnings). Pin the template to a reviewed tag or commit before enabling it as a
283
+ `examples/README.md` contains a GitHub Actions template for installing the pack from
284
+ PyPI, validating a rules directory, and failing the build on errors (and optionally
285
+ warnings). Pin the install to a reviewed release version before enabling it as a
178
286
  quality gate.
179
287
 
180
288
  ## Rule Pack Model
@@ -80,5 +80,7 @@ branch = true
80
80
  source = ["appsec_rules_pack"]
81
81
 
82
82
  [tool.coverage.report]
83
- fail_under = 90
83
+ # Raised from 90 once actual coverage settled above 97%. A gate far below the real
84
+ # number permits a silent regression the size of the gap.
85
+ fail_under = 95
84
86
  show_missing = true
@@ -2,4 +2,4 @@
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0"
5
+ __version__ = "0.3.0"
@@ -111,6 +111,23 @@ def _format_file_issue(base_path: Path, file_path: Path, issue: ValidationIssue)
111
111
  return f"{_display_path(base_path, file_path)}: {_format_issue(issue)}"
112
112
 
113
113
 
114
+ def _write_document(output: Path, document: str) -> None:
115
+ """Write a derived document, reporting write failures as an actionable CLI error.
116
+
117
+ Pointing ``--output`` at an existing directory, or at any path that cannot be
118
+ written, used to escape as a raw Python traceback (``PermissionError`` on Windows,
119
+ ``IsADirectoryError`` on POSIX). The caller keeps its own success message.
120
+ """
121
+
122
+ try:
123
+ output.parent.mkdir(parents=True, exist_ok=True)
124
+ output.write_text(document, encoding="utf-8", newline="\n")
125
+ except OSError as error:
126
+ reason = error.strerror or str(error)
127
+ typer.echo(f"Write failed: cannot write to {output}: {reason}.", err=True)
128
+ raise typer.Exit(code=1) from error
129
+
130
+
114
131
  def _iter_rule_files(path: Path) -> tuple[Path, ...]:
115
132
  if path.is_file():
116
133
  return (path,)
@@ -273,8 +290,7 @@ def export_index(
273
290
  document = json.dumps(index, indent=2) + "\n"
274
291
 
275
292
  if output is not None:
276
- output.parent.mkdir(parents=True, exist_ok=True)
277
- output.write_text(document, encoding="utf-8")
293
+ _write_document(output, document)
278
294
  typer.echo(f"Wrote index for {_plural(len(rule_files), 'file', 'files')} to {output}.")
279
295
  return
280
296
 
@@ -302,8 +318,7 @@ def export_semgrep(
302
318
  document = SEMGREP_HEADER + body
303
319
 
304
320
  if output is not None:
305
- output.parent.mkdir(parents=True, exist_ok=True)
306
- output.write_text(document, encoding="utf-8")
321
+ _write_document(output, document)
307
322
  typer.echo(
308
323
  f"Wrote Semgrep scaffold for {_plural(len(rule_files), 'file', 'files')} to {output}."
309
324
  )
@@ -332,8 +347,7 @@ def export_sarif(
332
347
  document = json.dumps(sarif, indent=2) + "\n"
333
348
 
334
349
  if output is not None:
335
- output.parent.mkdir(parents=True, exist_ok=True)
336
- output.write_text(document, encoding="utf-8")
350
+ _write_document(output, document)
337
351
  typer.echo(
338
352
  f"Wrote SARIF rule-catalog for {_plural(len(rule_files), 'file', 'files')} to {output}."
339
353
  )
@@ -360,11 +374,21 @@ def report_coverage(
360
374
 
361
375
  coverage = build_coverage_from_files(list(rule_files))
362
376
 
377
+ if coverage["rules"] == 0:
378
+ # The report is derived without schema validation, so a pack whose `rules` key is
379
+ # missing or malformed still produces a clean-looking 0/0 report. Say so on stderr
380
+ # rather than letting an empty report read as a healthy one. The exit code stays 0:
381
+ # reporting is derivation, and `validate` is what judges pack structure.
382
+ typer.echo(
383
+ f"Warning: no rules found in {rules_path}; the coverage report is empty. "
384
+ "Run `validate` to check the pack structure.",
385
+ err=True,
386
+ )
387
+
363
388
  if output_format is OutputFormat.json:
364
389
  document = json.dumps(coverage, indent=2) + "\n"
365
390
  if output is not None:
366
- output.parent.mkdir(parents=True, exist_ok=True)
367
- output.write_text(document, encoding="utf-8")
391
+ _write_document(output, document)
368
392
  typer.echo(f"Wrote coverage report to {output}.")
369
393
  return
370
394
  typer.echo(document, nl=False)
@@ -181,14 +181,18 @@ def validate_rules_payload(payload: Any, *, require_examples: bool = False) -> V
181
181
  schema = _load_schema()
182
182
  validator = jsonschema.Draft202012Validator(schema)
183
183
 
184
+ # jsonschema reports one error per missing required property, but the rendered
185
+ # message names every missing field at that location. Three missing fields therefore
186
+ # produced three byte-identical lines, and inflated the reported error count. An
187
+ # identical level+path+message carries no extra information, so it is dropped.
188
+ seen_schema_issues: set[tuple[str, tuple[Any, ...]]] = set()
184
189
  for error in sorted(validator.iter_errors(payload), key=lambda item: list(item.path)):
185
- issues.append(
186
- ValidationIssue(
187
- level="error",
188
- message=_schema_issue_message(error),
189
- path=tuple(error.absolute_path),
190
- )
191
- )
190
+ message = _schema_issue_message(error)
191
+ path = tuple(error.absolute_path)
192
+ if (message, path) in seen_schema_issues:
193
+ continue
194
+ seen_schema_issues.add((message, path))
195
+ issues.append(ValidationIssue(level="error", message=message, path=path))
192
196
 
193
197
  if isinstance(payload, dict):
194
198
  issues.extend(_semantic_issues(payload, require_examples=require_examples))