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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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)
|