commitguardian 0.1.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.
Files changed (197) hide show
  1. commitguard/__init__.py +26 -0
  2. commitguard/__main__.py +6 -0
  3. commitguard/api/__init__.py +18 -0
  4. commitguard/api/app.py +1376 -0
  5. commitguard/api/governance.py +1085 -0
  6. commitguard/api/hosting.py +196 -0
  7. commitguard/api/http.py +252 -0
  8. commitguard/api/settings.py +169 -0
  9. commitguard/audit/__init__.py +13 -0
  10. commitguard/audit/logger.py +34 -0
  11. commitguard/audit/models.py +222 -0
  12. commitguard/audit/storage.py +59 -0
  13. commitguard/ci/__init__.py +7 -0
  14. commitguard/ci/context.py +60 -0
  15. commitguard/cli/__init__.py +6 -0
  16. commitguard/cli/app.py +74 -0
  17. commitguard/cli/commands/__init__.py +1 -0
  18. commitguard/cli/commands/benchmark.py +441 -0
  19. commitguard/cli/commands/check.py +100 -0
  20. commitguard/cli/commands/ci.py +165 -0
  21. commitguard/cli/commands/dashboard.py +141 -0
  22. commitguard/cli/commands/doctor.py +533 -0
  23. commitguard/cli/commands/github.py +449 -0
  24. commitguard/cli/commands/hook.py +156 -0
  25. commitguard/cli/commands/init.py +137 -0
  26. commitguard/cli/commands/install.py +152 -0
  27. commitguard/cli/commands/policy.py +36 -0
  28. commitguard/cli/commands/report.py +39 -0
  29. commitguard/cli/commands/reproduce.py +123 -0
  30. commitguard/cli/commands/scan.py +47 -0
  31. commitguard/cli/common.py +44 -0
  32. commitguard/cli/output.py +89 -0
  33. commitguard/cli/render.py +367 -0
  34. commitguard/config/__init__.py +6 -0
  35. commitguard/config/defaults.py +53 -0
  36. commitguard/config/enforcement.py +53 -0
  37. commitguard/config/loader.py +174 -0
  38. commitguard/config/schema.py +105 -0
  39. commitguard/config/sources.py +183 -0
  40. commitguard/controlplane/__init__.py +24 -0
  41. commitguard/controlplane/access.py +231 -0
  42. commitguard/controlplane/commands.py +393 -0
  43. commitguard/controlplane/errors.py +88 -0
  44. commitguard/controlplane/identity.py +478 -0
  45. commitguard/controlplane/members.py +219 -0
  46. commitguard/controlplane/notifications.py +787 -0
  47. commitguard/controlplane/pagination.py +146 -0
  48. commitguard/controlplane/policies.py +1204 -0
  49. commitguard/controlplane/queries.py +1814 -0
  50. commitguard/controlplane/results.py +909 -0
  51. commitguard/controlplane/rules.py +184 -0
  52. commitguard/controlplane/views.py +799 -0
  53. commitguard/core/__init__.py +6 -0
  54. commitguard/core/context.py +31 -0
  55. commitguard/core/decision.py +58 -0
  56. commitguard/core/engine.py +82 -0
  57. commitguard/core/result.py +177 -0
  58. commitguard/detectors/__init__.py +6 -0
  59. commitguard/detectors/base.py +58 -0
  60. commitguard/detectors/bot.py +87 -0
  61. commitguard/detectors/coauthor.py +86 -0
  62. commitguard/detectors/identity.py +76 -0
  63. commitguard/detectors/registry.py +72 -0
  64. commitguard/detectors/trailer.py +211 -0
  65. commitguard/exceptions/__init__.py +33 -0
  66. commitguard/exceptions/base.py +9 -0
  67. commitguard/exceptions/configuration.py +22 -0
  68. commitguard/exceptions/detection.py +11 -0
  69. commitguard/exceptions/git.py +41 -0
  70. commitguard/exceptions/service.py +25 -0
  71. commitguard/git/__init__.py +12 -0
  72. commitguard/git/commands.py +101 -0
  73. commitguard/git/commit.py +97 -0
  74. commitguard/git/diff.py +36 -0
  75. commitguard/git/hooks.py +527 -0
  76. commitguard/git/push.py +93 -0
  77. commitguard/git/ranges.py +71 -0
  78. commitguard/git/repository.py +447 -0
  79. commitguard/github/__init__.py +34 -0
  80. commitguard/github/actions.py +163 -0
  81. commitguard/github/app.py +935 -0
  82. commitguard/github/auth.py +217 -0
  83. commitguard/github/check_runs.py +172 -0
  84. commitguard/github/checks.py +210 -0
  85. commitguard/github/client.py +844 -0
  86. commitguard/github/enforcement_status.py +209 -0
  87. commitguard/github/errors.py +129 -0
  88. commitguard/github/events.py +563 -0
  89. commitguard/github/identifiers.py +90 -0
  90. commitguard/github/installations.py +566 -0
  91. commitguard/github/markdown.py +19 -0
  92. commitguard/github/permissions.py +70 -0
  93. commitguard/github/pull_requests.py +53 -0
  94. commitguard/github/queue.py +47 -0
  95. commitguard/github/recovery.py +124 -0
  96. commitguard/github/repositories.py +305 -0
  97. commitguard/github/server.py +52 -0
  98. commitguard/github/settings.py +174 -0
  99. commitguard/github/storage.py +2315 -0
  100. commitguard/github/webhooks.py +129 -0
  101. commitguard/github/worker.py +628 -0
  102. commitguard/github/workflow.py +286 -0
  103. commitguard/governance/__init__.py +26 -0
  104. commitguard/governance/bulk.py +765 -0
  105. commitguard/governance/cache.py +88 -0
  106. commitguard/governance/common.py +216 -0
  107. commitguard/governance/exceptions.py +861 -0
  108. commitguard/governance/groups.py +448 -0
  109. commitguard/governance/inventory.py +386 -0
  110. commitguard/governance/posture.py +1272 -0
  111. commitguard/governance/resolver.py +632 -0
  112. commitguard/governance/rollouts.py +760 -0
  113. commitguard/governance/rules.py +371 -0
  114. commitguard/governance/schedules.py +663 -0
  115. commitguard/governance/service.py +120 -0
  116. commitguard/governance/settings.py +365 -0
  117. commitguard/governance/simulation.py +618 -0
  118. commitguard/governance/workflow.py +734 -0
  119. commitguard/notifications/__init__.py +2 -0
  120. commitguard/notifications/channels/__init__.py +1 -0
  121. commitguard/notifications/channels/base.py +22 -0
  122. commitguard/notifications/channels/email.py +110 -0
  123. commitguard/notifications/channels/in_app.py +74 -0
  124. commitguard/notifications/channels/sink.py +58 -0
  125. commitguard/notifications/channels/webhook.py +233 -0
  126. commitguard/notifications/deduplication.py +57 -0
  127. commitguard/notifications/dispatcher.py +201 -0
  128. commitguard/notifications/models.py +439 -0
  129. commitguard/notifications/outbox.py +106 -0
  130. commitguard/notifications/preferences.py +224 -0
  131. commitguard/notifications/retry.py +282 -0
  132. commitguard/notifications/service.py +128 -0
  133. commitguard/notifications/settings.py +167 -0
  134. commitguard/notifications/templates.py +108 -0
  135. commitguard/observability/__init__.py +5 -0
  136. commitguard/observability/logging.py +161 -0
  137. commitguard/observability/metrics.py +105 -0
  138. commitguard/policies/__init__.py +6 -0
  139. commitguard/policies/defaults.py +48 -0
  140. commitguard/policies/evaluator.py +66 -0
  141. commitguard/policies/governance.py +498 -0
  142. commitguard/policies/loader.py +23 -0
  143. commitguard/policies/mandatory.py +52 -0
  144. commitguard/policies/model.py +46 -0
  145. commitguard/provenance/__init__.py +9 -0
  146. commitguard/provenance/author.py +146 -0
  147. commitguard/provenance/committer.py +16 -0
  148. commitguard/provenance/normalization.py +158 -0
  149. commitguard/provenance/signatures.py +34 -0
  150. commitguard/provenance/trailers.py +256 -0
  151. commitguard/research/__init__.py +26 -0
  152. commitguard/research/compare.py +231 -0
  153. commitguard/research/datasets.py +1484 -0
  154. commitguard/research/detection.py +183 -0
  155. commitguard/research/environment.py +185 -0
  156. commitguard/research/gitenv.py +108 -0
  157. commitguard/research/hooks.py +247 -0
  158. commitguard/research/metrics.py +85 -0
  159. commitguard/research/performance.py +194 -0
  160. commitguard/research/platform.py +288 -0
  161. commitguard/research/report.py +372 -0
  162. commitguard/research/repository.py +111 -0
  163. commitguard/research/reproduction.py +297 -0
  164. commitguard/research/results.py +94 -0
  165. commitguard/rules/__init__.py +11 -0
  166. commitguard/rules/data/ai-domains.yaml +51 -0
  167. commitguard/rules/data/ai-identities.yaml +131 -0
  168. commitguard/rules/data/bot-identities.yaml +53 -0
  169. commitguard/rules/data/patterns.yaml +52 -0
  170. commitguard/rules/loader.py +102 -0
  171. commitguard/rules/matcher.py +212 -0
  172. commitguard/rules/models.py +269 -0
  173. commitguard/security/__init__.py +5 -0
  174. commitguard/security/hashing.py +30 -0
  175. commitguard/security/rate_limit.py +33 -0
  176. commitguard/security/safe_yaml.py +69 -0
  177. commitguard/security/sanitization.py +85 -0
  178. commitguard/security/secrets.py +169 -0
  179. commitguard/security/validation.py +89 -0
  180. commitguard/services/__init__.py +15 -0
  181. commitguard/services/analysis.py +119 -0
  182. commitguard/services/audit.py +95 -0
  183. commitguard/services/ci.py +383 -0
  184. commitguard/services/enforcement.py +102 -0
  185. commitguard/services/hooks.py +254 -0
  186. commitguard/services/remediation.py +99 -0
  187. commitguard/services/reports.py +146 -0
  188. commitguard/services/scan.py +172 -0
  189. commitguard/utils/__init__.py +1 -0
  190. commitguard/utils/filesystem.py +72 -0
  191. commitguard/utils/platform.py +35 -0
  192. commitguard/utils/subprocess.py +84 -0
  193. commitguardian-0.1.0.dist-info/METADATA +694 -0
  194. commitguardian-0.1.0.dist-info/RECORD +197 -0
  195. commitguardian-0.1.0.dist-info/WHEEL +4 -0
  196. commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
  197. commitguardian-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,563 @@
1
+ """GitHub event payloads -> normalised events.
2
+
3
+ ``parse_github_event`` turns an Actions event (or an App webhook for the same
4
+ event) into a :class:`~commitguard.ci.context.CIContext`; ``normalize_webhook``
5
+ additionally validates GitHub App webhooks (installation, repository identity)
6
+ and returns one of the typed events defined at the end of this module.
7
+
8
+ Only the fields CommitGuard needs are read; raw JSON never travels further
9
+ into the application. Payloads are untrusted input: SHAs are validated, ref
10
+ names checked for control characters, and anything unexpected is an error
11
+ (the check fails closed).
12
+
13
+ Supported events:
14
+
15
+ * ``pull_request`` - base/head SHAs from ``pull_request.base.sha`` / ``.head.sha``.
16
+ These are the PR's real commits; GitHub's synthetic ``refs/pull/N/merge``
17
+ commit that ``actions/checkout`` checks out is never analysed.
18
+ * ``merge_group`` - merge queue: ``merge_group.base_sha`` / ``.head_sha``.
19
+ * ``push`` - ``before`` / ``after`` (all-zero = new / deleted ref).
20
+
21
+ ``pull_request_target`` is rejected: it runs with a privileged token in the
22
+ context of the base repository, and CommitGuard never needs that.
23
+ """
24
+
25
+ import json
26
+ import re
27
+ from enum import StrEnum
28
+ from pathlib import Path
29
+ from typing import Any, Literal
30
+
31
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
32
+
33
+ from commitguard.ci.context import CIContext, CIEventKind, CIProvider
34
+ from commitguard.exceptions.base import CommitGuardError, UnsafeInputError
35
+ from commitguard.github.errors import WebhookValidationError
36
+ from commitguard.github.identifiers import MAX_GITHUB_ID, GitHubAccount, RepositoryRef
37
+ from commitguard.security.validation import validate_git_sha
38
+ from commitguard.utils.filesystem import read_bytes_limited
39
+
40
+ MAX_EVENT_BYTES = 64 * 1024 * 1024
41
+ ZERO_OID_LENGTHS = (40, 64)
42
+
43
+
44
+ class GitHubEventError(CommitGuardError):
45
+ """The GitHub event is unsupported, missing or malformed."""
46
+
47
+
48
+ def _is_zero(oid: str) -> bool:
49
+ return len(oid) in ZERO_OID_LENGTHS and set(oid) == {"0"}
50
+
51
+
52
+ def _clean_ref(ref: object, field: str) -> str | None:
53
+ if ref is None:
54
+ return None
55
+ if not isinstance(ref, str) or not ref or len(ref) > 4096:
56
+ raise GitHubEventError(f"event field {field} is not a valid ref name")
57
+ if any(ord(c) < 0x20 or ord(c) == 0x7F for c in ref):
58
+ raise GitHubEventError(f"event field {field} contains control characters")
59
+ return ref
60
+
61
+
62
+ class _Loose(BaseModel):
63
+ model_config = ConfigDict(extra="ignore", frozen=True)
64
+
65
+
66
+ class _Repo(_Loose):
67
+ full_name: str | None = None
68
+ default_branch: str | None = None
69
+
70
+
71
+ class _PRSide(_Loose):
72
+ sha: str
73
+ ref: str | None = None
74
+ repo: _Repo | None = None
75
+
76
+
77
+ class _PullRequest(_Loose):
78
+ number: int
79
+ base: _PRSide
80
+ head: _PRSide
81
+
82
+
83
+ class GitHubPullRequestContext(_Loose):
84
+ pull_request: _PullRequest
85
+ repository: _Repo | None = None
86
+
87
+
88
+ class _MergeGroup(_Loose):
89
+ base_sha: str
90
+ head_sha: str
91
+ base_ref: str | None = None
92
+
93
+
94
+ class GitHubMergeGroupContext(_Loose):
95
+ merge_group: _MergeGroup
96
+ repository: _Repo | None = None
97
+
98
+
99
+ class GitHubPushContext(_Loose):
100
+ ref: str
101
+ before: str
102
+ after: str
103
+ deleted: bool = False
104
+ repository: _Repo | None = None
105
+
106
+
107
+ def _repo_fields(repo: _Repo | None) -> dict[str, Any]:
108
+ if repo is None:
109
+ return {}
110
+ return {
111
+ "repository": _clean_ref(repo.full_name, "repository.full_name"),
112
+ "default_branch": _clean_ref(repo.default_branch, "repository.default_branch"),
113
+ }
114
+
115
+
116
+ def parse_github_event(event_name: str, payload: object) -> CIContext:
117
+ """Normalise a GitHub event payload."""
118
+ if not isinstance(payload, dict):
119
+ raise GitHubEventError("event payload must be a JSON object")
120
+ try:
121
+ if event_name == "pull_request":
122
+ pr_event = GitHubPullRequestContext.model_validate(payload)
123
+ pr = pr_event.pull_request
124
+ base_repo = pr.base.repo.full_name if pr.base.repo else None
125
+ head_repo = pr.head.repo.full_name if pr.head.repo else None
126
+ return CIContext(
127
+ provider=CIProvider.GITHUB,
128
+ event=CIEventKind.PULL_REQUEST,
129
+ event_name=event_name,
130
+ ref=_clean_ref(f"refs/heads/{pr.base.ref}" if pr.base.ref else None, "base.ref"),
131
+ base_sha=pr.base.sha,
132
+ head_sha=pr.head.sha,
133
+ pull_request_number=pr.number,
134
+ # A deleted head repository (head.repo == null) is treated as a fork.
135
+ from_fork=head_repo is None or head_repo != base_repo,
136
+ **_repo_fields(pr_event.repository),
137
+ )
138
+ if event_name == "merge_group":
139
+ mg_event = GitHubMergeGroupContext.model_validate(payload)
140
+ mg = mg_event.merge_group
141
+ return CIContext(
142
+ provider=CIProvider.GITHUB,
143
+ event=CIEventKind.MERGE_GROUP,
144
+ event_name=event_name,
145
+ ref=_clean_ref(mg.base_ref, "merge_group.base_ref"),
146
+ base_sha=mg.base_sha,
147
+ head_sha=mg.head_sha,
148
+ **_repo_fields(mg_event.repository),
149
+ )
150
+ if event_name == "push":
151
+ push = GitHubPushContext.model_validate(payload)
152
+ deleted = _is_zero(push.after)
153
+ if push.deleted != deleted:
154
+ raise GitHubEventError("push event: `deleted` contradicts the `after` commit")
155
+ return CIContext(
156
+ provider=CIProvider.GITHUB,
157
+ event=CIEventKind.PUSH,
158
+ event_name=event_name,
159
+ ref=_clean_ref(push.ref, "ref"),
160
+ before_sha=None if _is_zero(push.before) else push.before,
161
+ after_sha=None if deleted else push.after,
162
+ ref_deleted=deleted,
163
+ **_repo_fields(push.repository),
164
+ )
165
+ except ValidationError as exc:
166
+ fields = sorted({".".join(str(p) for p in e["loc"]) for e in exc.errors()})
167
+ raise GitHubEventError(
168
+ f"malformed {event_name} event payload (fields: {', '.join(fields) or '?'})"
169
+ ) from exc
170
+ if event_name == "pull_request_target":
171
+ raise GitHubEventError(
172
+ "pull_request_target is not supported: use the pull_request event "
173
+ "(CommitGuard needs no secrets or write access)"
174
+ )
175
+ raise GitHubEventError(
176
+ f"unsupported GitHub event {event_name!r} (supported: pull_request, push, merge_group)"
177
+ )
178
+
179
+
180
+ def load_github_event(event_name: str | None, event_path: Path | None) -> CIContext:
181
+ """Read ``GITHUB_EVENT_NAME`` / ``GITHUB_EVENT_PATH`` style inputs."""
182
+ if not event_name:
183
+ raise GitHubEventError("GitHub event name missing (GITHUB_EVENT_NAME / --event-name)")
184
+ if event_path is None:
185
+ raise GitHubEventError("GitHub event payload missing (GITHUB_EVENT_PATH / --event-path)")
186
+ try:
187
+ raw = read_bytes_limited(event_path, max_bytes=MAX_EVENT_BYTES)
188
+ payload = json.loads(raw.decode("utf-8"))
189
+ except FileNotFoundError as exc:
190
+ raise GitHubEventError(f"event payload file not found: {event_path}") from exc
191
+ except (OSError, ValueError, CommitGuardError) as exc:
192
+ raise GitHubEventError(f"event payload could not be read as JSON: {exc}") from exc
193
+ return parse_github_event(event_name, payload)
194
+
195
+
196
+ # --------------------------------------------------------------------------- #
197
+ # GitHub App webhooks -> normalised events
198
+ # --------------------------------------------------------------------------- #
199
+ # Webhook payloads for push and pull_request have the same shape as the
200
+ # Actions event payloads above, so the commit range and trust decisions are
201
+ # made by the same parser for both the GitHub Action and the GitHub App.
202
+
203
+ SUPPORTED_WEBHOOK_EVENTS = frozenset(
204
+ {
205
+ "installation",
206
+ "installation_repositories",
207
+ "pull_request",
208
+ "push",
209
+ "merge_group",
210
+ "check_run",
211
+ "check_suite",
212
+ }
213
+ )
214
+ PULL_REQUEST_SCAN_ACTIONS = frozenset({"opened", "synchronize", "reopened"})
215
+ MAX_EVENT_REPOSITORIES = 50_000
216
+ MAX_EVENT_PULL_REQUESTS = 100
217
+ #: GitHub creates merge group commits on refs under this prefix
218
+ #: (``refs/heads/gh-readonly-queue/{base_branch}/pr-{number}-{sha}``).
219
+ MERGE_QUEUE_REF_PREFIX = "refs/heads/gh-readonly-queue/"
220
+ _MERGE_QUEUE_PR_RE = re.compile(r"/pr-([1-9][0-9]{0,9})-[0-9a-f]{40,64}\Z")
221
+ _HEX_ID_RE = re.compile(r"\A[0-9a-f]{32}\Z")
222
+
223
+
224
+ class InstallationAction(StrEnum):
225
+ CREATED = "created"
226
+ DELETED = "deleted"
227
+ SUSPEND = "suspend"
228
+ UNSUSPEND = "unsuspend"
229
+ NEW_PERMISSIONS_ACCEPTED = "new_permissions_accepted"
230
+
231
+
232
+ class RepositoriesAction(StrEnum):
233
+ ADDED = "added"
234
+ REMOVED = "removed"
235
+
236
+
237
+ class _Strict(BaseModel):
238
+ model_config = ConfigDict(frozen=True, extra="forbid")
239
+
240
+
241
+ class InstallationEvent(_Strict):
242
+ kind: Literal["installation"] = "installation"
243
+ action: InstallationAction
244
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
245
+ account: GitHubAccount
246
+ repository_selection: Literal["all", "selected"]
247
+ repositories: tuple[RepositoryRef, ...] = ()
248
+ permissions: dict[str, str] = {}
249
+
250
+
251
+ class InstallationRepositoriesEvent(_Strict):
252
+ kind: Literal["installation_repositories"] = "installation_repositories"
253
+ action: RepositoriesAction
254
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
255
+ account: GitHubAccount
256
+ repository_selection: Literal["all", "selected"]
257
+ added: tuple[RepositoryRef, ...] = ()
258
+ removed: tuple[RepositoryRef, ...] = ()
259
+
260
+
261
+ class PushEvent(_Strict):
262
+ kind: Literal["push"] = "push"
263
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
264
+ repository: RepositoryRef
265
+ context: CIContext
266
+
267
+
268
+ class PullRequestEvent(_Strict):
269
+ kind: Literal["pull_request"] = "pull_request"
270
+ action: str = Field(pattern=r"^[a-z_]{1,64}$")
271
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
272
+ repository: RepositoryRef
273
+ number: int = Field(gt=0, lt=MAX_GITHUB_ID)
274
+ merged: bool = False
275
+ base_changed: bool = False
276
+ context: CIContext
277
+
278
+
279
+ class MergeGroupAction(StrEnum):
280
+ CHECKS_REQUESTED = "checks_requested"
281
+ DESTROYED = "destroyed"
282
+
283
+
284
+ class MergeGroupEvent(_Strict):
285
+ """A merge queue candidate commit (``merge_group`` webhook).
286
+
287
+ ``head_sha`` is the temporary merge group commit GitHub expects required checks
288
+ on; ``base_sha`` is its parent on the target branch. ``pull_requests`` is parsed
289
+ from GitHub's ref naming for display only and never used for authorization.
290
+ """
291
+
292
+ kind: Literal["merge_group"] = "merge_group"
293
+ action: MergeGroupAction
294
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
295
+ repository: RepositoryRef
296
+ head_sha: str
297
+ head_ref: str
298
+ base_sha: str
299
+ base_ref: str
300
+ reason: Literal["merged", "invalidated", "dequeued"] | None = None
301
+ pull_requests: tuple[int, ...] = ()
302
+ context: CIContext
303
+
304
+
305
+ class CheckRunRerequestedEvent(_Strict):
306
+ """Someone chose "Re-run" on a check run (``check_run`` ``rerequested``)."""
307
+
308
+ kind: Literal["check_run_rerequested"] = "check_run_rerequested"
309
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
310
+ repository: RepositoryRef
311
+ check_run_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
312
+ name: str = Field(min_length=1, max_length=200)
313
+ head_sha: str
314
+ external_id: str | None
315
+ app_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
316
+
317
+
318
+ class CheckSuiteRerequestedEvent(_Strict):
319
+ """Someone chose "Re-run all checks" (``check_suite`` ``rerequested``)."""
320
+
321
+ kind: Literal["check_suite_rerequested"] = "check_suite_rerequested"
322
+ installation_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
323
+ repository: RepositoryRef
324
+ check_suite_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
325
+ head_sha: str
326
+ app_id: int = Field(gt=0, lt=MAX_GITHUB_ID)
327
+
328
+
329
+ class IgnoredEvent(_Strict):
330
+ kind: Literal["ignored"] = "ignored"
331
+ event: str
332
+ action: str | None = None
333
+ reason: str
334
+
335
+
336
+ GitHubWebhookEvent = (
337
+ InstallationEvent
338
+ | InstallationRepositoriesEvent
339
+ | PushEvent
340
+ | PullRequestEvent
341
+ | MergeGroupEvent
342
+ | CheckRunRerequestedEvent
343
+ | CheckSuiteRerequestedEvent
344
+ | IgnoredEvent
345
+ )
346
+
347
+
348
+ def _require(mapping: object, key: str, kind: type | tuple[type, ...], field: str) -> Any:
349
+ value = mapping.get(key) if isinstance(mapping, dict) else None
350
+ if not isinstance(value, kind) or (isinstance(value, bool) and kind is int):
351
+ raise WebhookValidationError(f"webhook payload field {field} is missing or invalid")
352
+ return value
353
+
354
+
355
+ def _installation_id(payload: dict[str, Any]) -> int:
356
+ installation = _require(payload, "installation", dict, "installation")
357
+ value = _require(installation, "id", int, "installation.id")
358
+ if not 0 < value < MAX_GITHUB_ID:
359
+ raise WebhookValidationError("webhook payload field installation.id is invalid")
360
+ return int(value)
361
+
362
+
363
+ def _account(installation: dict[str, Any]) -> GitHubAccount:
364
+ account = _require(installation, "account", dict, "installation.account")
365
+ return GitHubAccount(
366
+ id=_require(account, "id", int, "installation.account.id"),
367
+ login=_require(account, "login", str, "installation.account.login"),
368
+ type=_require(account, "type", str, "installation.account.type"),
369
+ )
370
+
371
+
372
+ def _repository_list(payload: dict[str, Any], key: str) -> tuple[RepositoryRef, ...]:
373
+ items = payload.get(key)
374
+ if items is None:
375
+ return ()
376
+ if not isinstance(items, list) or len(items) > MAX_EVENT_REPOSITORIES:
377
+ raise WebhookValidationError(f"webhook payload field {key} is invalid")
378
+ return tuple(
379
+ RepositoryRef.from_full_name(
380
+ _require(item, "id", int, f"{key}[].id"),
381
+ _require(item, "full_name", str, f"{key}[].full_name"),
382
+ )
383
+ for item in items
384
+ )
385
+
386
+
387
+ def _event_repository(payload: dict[str, Any]) -> RepositoryRef:
388
+ repository = _require(payload, "repository", dict, "repository")
389
+ ref = RepositoryRef.from_full_name(
390
+ _require(repository, "id", int, "repository.id"),
391
+ _require(repository, "full_name", str, "repository.full_name"),
392
+ )
393
+ owner = repository.get("owner")
394
+ login = owner.get("login") if isinstance(owner, dict) else None
395
+ if repository.get("name") not in (None, ref.name) or login not in (None, ref.owner):
396
+ raise WebhookValidationError("webhook repository name fields are inconsistent")
397
+ return ref
398
+
399
+
400
+ def _permissions_field(installation: dict[str, Any]) -> dict[str, str]:
401
+ raw = installation.get("permissions") or {}
402
+ if not isinstance(raw, dict) or len(raw) > 200:
403
+ raise WebhookValidationError("webhook payload field installation.permissions is invalid")
404
+ return {
405
+ k: v
406
+ for k, v in raw.items()
407
+ if isinstance(k, str) and isinstance(v, str) and len(k) <= 64 and len(v) <= 16
408
+ }
409
+
410
+
411
+ def normalize_webhook(event_name: str, payload: object) -> GitHubWebhookEvent:
412
+ """Validate a verified webhook payload and reduce it to a normalised event.
413
+
414
+ Raises :class:`WebhookValidationError` (HTTP 400) when a supported event is
415
+ missing mandatory fields; unsupported events are returned as
416
+ :class:`IgnoredEvent` so GitHub is not told to retry them.
417
+ """
418
+ if not isinstance(payload, dict):
419
+ raise WebhookValidationError("webhook payload must be a JSON object")
420
+ raw_action = payload.get("action")
421
+ action = raw_action if isinstance(raw_action, str) and len(raw_action) <= 64 else None
422
+ if event_name not in SUPPORTED_WEBHOOK_EVENTS:
423
+ reason = "ping" if event_name == "ping" else "event not used by CommitGuard"
424
+ return IgnoredEvent(event=event_name, action=action, reason=reason)
425
+ try:
426
+ if event_name == "installation":
427
+ if action not in set(InstallationAction):
428
+ return IgnoredEvent(event=event_name, action=action, reason="action not used")
429
+ installation = _require(payload, "installation", dict, "installation")
430
+ return InstallationEvent(
431
+ action=InstallationAction(action),
432
+ installation_id=_installation_id(payload),
433
+ account=_account(installation),
434
+ repository_selection=installation.get("repository_selection", "selected"),
435
+ repositories=_repository_list(payload, "repositories"),
436
+ permissions=_permissions_field(installation),
437
+ )
438
+ if event_name == "installation_repositories":
439
+ if action not in set(RepositoriesAction):
440
+ return IgnoredEvent(event=event_name, action=action, reason="action not used")
441
+ installation = _require(payload, "installation", dict, "installation")
442
+ return InstallationRepositoriesEvent(
443
+ action=RepositoriesAction(action),
444
+ installation_id=_installation_id(payload),
445
+ account=_account(installation),
446
+ repository_selection=_require(
447
+ payload, "repository_selection", str, "repository_selection"
448
+ ),
449
+ added=_repository_list(payload, "repositories_added"),
450
+ removed=_repository_list(payload, "repositories_removed"),
451
+ )
452
+ if event_name in ("check_run", "check_suite"):
453
+ return _normalize_rerun(event_name, action, payload)
454
+ installation_id = _installation_id(payload)
455
+ repository = _event_repository(payload)
456
+ if event_name == "merge_group" and action not in set(MergeGroupAction):
457
+ return IgnoredEvent(event=event_name, action=action, reason="action not used")
458
+ context = parse_github_event(event_name, payload)
459
+ if context.repository != repository.full_name:
460
+ raise WebhookValidationError("webhook repository fields are inconsistent")
461
+ if event_name == "merge_group":
462
+ return _merge_group_event(action, installation_id, repository, payload, context)
463
+ if event_name == "push":
464
+ return PushEvent(
465
+ installation_id=installation_id, repository=repository, context=context
466
+ )
467
+ if action is None:
468
+ raise WebhookValidationError("webhook payload field action is missing or invalid")
469
+ pull_request = _require(payload, "pull_request", dict, "pull_request")
470
+ changes = payload.get("changes")
471
+ return PullRequestEvent(
472
+ action=action,
473
+ installation_id=installation_id,
474
+ repository=repository,
475
+ number=_require(pull_request, "number", int, "pull_request.number"),
476
+ merged=pull_request.get("merged") is True,
477
+ base_changed=isinstance(changes, dict) and "base" in changes,
478
+ context=context,
479
+ )
480
+ except GitHubEventError as exc:
481
+ raise WebhookValidationError(str(exc)) from None
482
+ except (ValidationError, UnsafeInputError, ValueError) as exc:
483
+ detail = "invalid identifiers" if isinstance(exc, UnsafeInputError) else "invalid fields"
484
+ raise WebhookValidationError(f"malformed {event_name} webhook payload ({detail})") from None
485
+
486
+
487
+ def _sha(value: object, field: str) -> str:
488
+ if not isinstance(value, str):
489
+ raise WebhookValidationError(f"webhook payload field {field} is missing or invalid")
490
+ try:
491
+ return validate_git_sha(value)
492
+ except (UnsafeInputError, ValueError):
493
+ raise WebhookValidationError(f"webhook payload field {field} is invalid") from None
494
+
495
+
496
+ def _merge_group_event(
497
+ action: str | None,
498
+ installation_id: int,
499
+ repository: RepositoryRef,
500
+ payload: dict[str, Any],
501
+ context: CIContext,
502
+ ) -> MergeGroupEvent:
503
+ group = _require(payload, "merge_group", dict, "merge_group")
504
+ head_ref = _clean_ref(_require(group, "head_ref", str, "merge_group.head_ref"), "head_ref")
505
+ base_ref = _clean_ref(_require(group, "base_ref", str, "merge_group.base_ref"), "base_ref")
506
+ if head_ref is None or base_ref is None: # pragma: no cover - _require checked the type
507
+ raise WebhookValidationError("merge group refs are missing")
508
+ base_branch = base_ref.removeprefix("refs/heads/")
509
+ # A merge group commit lives on GitHub's read-only queue ref for its base branch.
510
+ if not base_ref.startswith("refs/heads/") or not head_ref.startswith(
511
+ f"{MERGE_QUEUE_REF_PREFIX}{base_branch}/"
512
+ ):
513
+ raise WebhookValidationError("merge group refs do not describe a merge queue")
514
+ head_commit = group.get("head_commit")
515
+ if isinstance(head_commit, dict) and head_commit.get("id") not in (None, context.head_sha):
516
+ raise WebhookValidationError("merge group head commit is inconsistent")
517
+ match = _MERGE_QUEUE_PR_RE.search(head_ref)
518
+ reason = payload.get("reason")
519
+ return MergeGroupEvent(
520
+ action=MergeGroupAction(action or ""),
521
+ installation_id=installation_id,
522
+ repository=repository,
523
+ head_sha=context.head_sha or "",
524
+ head_ref=head_ref,
525
+ base_sha=context.base_sha or "",
526
+ base_ref=base_ref,
527
+ reason=reason if reason in ("merged", "invalidated", "dequeued") else None,
528
+ pull_requests=(int(match.group(1)),) if match else (),
529
+ context=context.model_copy(update={"ref": base_ref}),
530
+ )
531
+
532
+
533
+ def _normalize_rerun(
534
+ event_name: str, action: str | None, payload: dict[str, Any]
535
+ ) -> GitHubWebhookEvent:
536
+ if action != "rerequested":
537
+ return IgnoredEvent(event=event_name, action=action, reason="action not used")
538
+ installation_id = _installation_id(payload)
539
+ repository = _event_repository(payload)
540
+ if event_name == "check_run":
541
+ run = _require(payload, "check_run", dict, "check_run")
542
+ app = _require(run, "app", dict, "check_run.app")
543
+ external_id = run.get("external_id")
544
+ return CheckRunRerequestedEvent(
545
+ installation_id=installation_id,
546
+ repository=repository,
547
+ check_run_id=_require(run, "id", int, "check_run.id"),
548
+ name=_require(run, "name", str, "check_run.name"),
549
+ head_sha=_sha(run.get("head_sha"), "check_run.head_sha"),
550
+ external_id=external_id
551
+ if isinstance(external_id, str) and _HEX_ID_RE.match(external_id)
552
+ else None,
553
+ app_id=_require(app, "id", int, "check_run.app.id"),
554
+ )
555
+ suite = _require(payload, "check_suite", dict, "check_suite")
556
+ app = _require(suite, "app", dict, "check_suite.app")
557
+ return CheckSuiteRerequestedEvent(
558
+ installation_id=installation_id,
559
+ repository=repository,
560
+ check_suite_id=_require(suite, "id", int, "check_suite.id"),
561
+ head_sha=_sha(suite.get("head_sha"), "check_suite.head_sha"),
562
+ app_id=_require(app, "id", int, "check_suite.app.id"),
563
+ )
@@ -0,0 +1,90 @@
1
+ """Validated GitHub identifiers (accounts, repositories).
2
+
3
+ Repository and account names come from webhook payloads and API responses and
4
+ are untrusted. They are only ever used as data: in URL path segments (after
5
+ validation and percent-encoding), in Check Run text (after escaping) and in
6
+ logs. Numeric IDs, not names, are used for authorization, storage keys and
7
+ file-system paths, so a rename or a crafted name cannot confuse tenants.
8
+ """
9
+
10
+ import re
11
+ from enum import StrEnum
12
+
13
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
14
+
15
+ from commitguard.exceptions.base import UnsafeInputError
16
+
17
+ # GitHub logins: alphanumerics and single hyphens, max 39 chars. Bot accounts
18
+ # (e.g. "dependabot[bot]") only appear as senders, never as repository owners.
19
+ _LOGIN_RE = re.compile(r"\A[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}\Z")
20
+ _REPO_NAME_RE = re.compile(r"\A[A-Za-z0-9._-]{1,100}\Z")
21
+ MAX_GITHUB_ID = 2**53
22
+
23
+
24
+ def validate_login(value: str) -> str:
25
+ if not isinstance(value, str) or not _LOGIN_RE.match(value):
26
+ raise UnsafeInputError("invalid GitHub account login")
27
+ return value
28
+
29
+
30
+ def validate_repository_name(value: str) -> str:
31
+ if not isinstance(value, str) or not _REPO_NAME_RE.match(value) or value in (".", ".."):
32
+ raise UnsafeInputError("invalid GitHub repository name")
33
+ if value.lower().endswith(".git"):
34
+ raise UnsafeInputError("invalid GitHub repository name")
35
+ return value
36
+
37
+
38
+ def split_full_name(full_name: str) -> tuple[str, str]:
39
+ if not isinstance(full_name, str) or full_name.count("/") != 1:
40
+ raise UnsafeInputError("invalid GitHub repository full name")
41
+ owner, name = full_name.split("/")
42
+ return validate_login(owner), validate_repository_name(name)
43
+
44
+
45
+ class AccountType(StrEnum):
46
+ USER = "User"
47
+ ORGANIZATION = "Organization"
48
+ ENTERPRISE = "Enterprise"
49
+
50
+
51
+ class GitHubAccount(BaseModel):
52
+ model_config = ConfigDict(frozen=True, extra="forbid")
53
+
54
+ id: int = Field(gt=0, lt=MAX_GITHUB_ID)
55
+ login: str
56
+ type: AccountType
57
+
58
+ @field_validator("login")
59
+ @classmethod
60
+ def _login(cls, value: str) -> str:
61
+ return validate_login(value)
62
+
63
+
64
+ class RepositoryRef(BaseModel):
65
+ """A repository identified by its immutable numeric ID plus its current name."""
66
+
67
+ model_config = ConfigDict(frozen=True, extra="forbid")
68
+
69
+ id: int = Field(gt=0, lt=MAX_GITHUB_ID)
70
+ owner: str
71
+ name: str
72
+
73
+ @field_validator("owner")
74
+ @classmethod
75
+ def _owner(cls, value: str) -> str:
76
+ return validate_login(value)
77
+
78
+ @field_validator("name")
79
+ @classmethod
80
+ def _name(cls, value: str) -> str:
81
+ return validate_repository_name(value)
82
+
83
+ @property
84
+ def full_name(self) -> str:
85
+ return f"{self.owner}/{self.name}"
86
+
87
+ @classmethod
88
+ def from_full_name(cls, repository_id: int, full_name: str) -> "RepositoryRef":
89
+ owner, name = split_full_name(full_name)
90
+ return cls(id=repository_id, owner=owner, name=name)