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,566 @@
1
+ """Installation lifecycle and repository authorization.
2
+
3
+ Two sources of truth, used for different decisions:
4
+
5
+ * **Stored installation state** (from ``installation`` /
6
+ ``installation_repositories`` webhooks) gives fast *negative* answers: a
7
+ deleted or suspended installation is rejected before anything else happens,
8
+ and its mirrors and cached tokens are discarded.
9
+ * **GitHub itself** gives the authoritative *positive* answer: before any
10
+ repository access the worker mints an installation token down-scoped to that
11
+ single repository (GitHub refuses if the installation does not cover it) and
12
+ looks the repository up by its immutable ID with that token. A missing or
13
+ missed webhook therefore cannot grant access, and a payload naming an
14
+ unrelated installation, owner or repository gets nothing.
15
+
16
+ Connection state transitions are detected against the stored state, inside the
17
+ same transaction that applies them:
18
+
19
+ ====================================== =============================================
20
+ Transition Notification
21
+ ====================================== =============================================
22
+ active -> suspended / deleted ``installation_disconnected`` (enforcement
23
+ at risk)
24
+ suspended -> active (unsuspend) ``installation_reconnected``
25
+ new installation for an account whose ``installation_reconnected``
26
+ previous installation was removed
27
+ suspended -> deleted, deleted -> none: already disconnected
28
+ deleted, repeated events
29
+ ====================================== =============================================
30
+
31
+ Webhooks can arrive out of order. An installation stored as ``deleted`` is
32
+ not revived by a late ``suspend``, ``unsuspend`` or ``new_permissions_accepted``
33
+ event; only ``created`` makes it active again.
34
+ """
35
+
36
+ import sqlite3
37
+ from collections.abc import Callable, Sequence
38
+ from dataclasses import dataclass
39
+ from datetime import UTC, datetime
40
+ from typing import Any
41
+
42
+ from commitguard.audit.models import GITHUB_ACTOR, Actor, AuditEventType
43
+ from commitguard.core.result import Severity
44
+ from commitguard.github.auth import InstallationToken, InstallationTokenProvider
45
+ from commitguard.github.client import GitHubClient, InstallationInfo
46
+ from commitguard.github.errors import (
47
+ AuthenticationError,
48
+ AuthorizationError,
49
+ GitHubAPIError,
50
+ GitHubForbiddenError,
51
+ GitHubNotFoundError,
52
+ GitHubUnauthorizedError,
53
+ )
54
+ from commitguard.github.events import (
55
+ InstallationAction,
56
+ InstallationEvent,
57
+ InstallationRepositoriesEvent,
58
+ RepositoriesAction,
59
+ )
60
+ from commitguard.github.identifiers import AccountType, RepositoryRef
61
+ from commitguard.github.repositories import MirrorManager
62
+ from commitguard.github.storage import InstallationRecord, InstallationState, SqliteStateStore
63
+ from commitguard.notifications.deduplication import domain_key
64
+ from commitguard.notifications.models import NotificationEvent, NotificationType
65
+ from commitguard.notifications.outbox import emit
66
+ from commitguard.observability.logging import get_logger
67
+ from commitguard.services.audit import AuditService
68
+
69
+ log = get_logger(__name__)
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class RepositorySync:
74
+ installation_id: int
75
+ repositories: tuple[RepositoryRef, ...]
76
+ added: tuple[RepositoryRef, ...]
77
+ removed: tuple[RepositoryRef, ...]
78
+ synced_at: datetime
79
+
80
+
81
+ @dataclass(frozen=True, slots=True)
82
+ class AuthorizedRepository:
83
+ installation_id: int
84
+ repository: RepositoryRef # canonical owner/name from the API
85
+ token: InstallationToken
86
+ default_branch: str | None
87
+
88
+
89
+ class InstallationService:
90
+ def __init__(
91
+ self,
92
+ store: SqliteStateStore,
93
+ tokens: InstallationTokenProvider,
94
+ client: GitHubClient,
95
+ mirrors: MirrorManager,
96
+ audit: AuditService,
97
+ *,
98
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
99
+ ) -> None:
100
+ self._store = store
101
+ self._tokens = tokens
102
+ self._client = client
103
+ self._mirrors = mirrors
104
+ self._audit = audit
105
+ self._now = now
106
+ self._discovery_listeners: list[Callable[[int, int, Sequence[RepositoryRef]], None]] = []
107
+
108
+ def add_discovery_listener(
109
+ self, listener: Callable[[int, int, Sequence[RepositoryRef]], None]
110
+ ) -> None:
111
+ """Call ``listener(account_id, installation_id, repositories)`` for listed repositories.
112
+
113
+ Organization governance uses it to record newly discovered repositories
114
+ and apply the organization's onboarding defaults. A listener failure is
115
+ logged and never fails the GitHub event.
116
+ """
117
+ self._discovery_listeners.append(listener)
118
+
119
+ def _discovered(
120
+ self, account_id: int, installation_id: int, repositories: Sequence[RepositoryRef]
121
+ ) -> None:
122
+ if not repositories:
123
+ return
124
+ for listener in self._discovery_listeners:
125
+ try:
126
+ listener(account_id, installation_id, repositories)
127
+ except Exception as exc: # noqa: BLE001 - discovery must not fail the event
128
+ log.error("repository_discovery_listener_failed", error_type=type(exc).__name__)
129
+
130
+ # -- webhooks -------------------------------------------------------- #
131
+ def handle_installation(self, event: InstallationEvent) -> None:
132
+ now = self._now()
133
+ existing = self._store.get_installation(event.installation_id)
134
+ record = InstallationRecord(
135
+ installation_id=event.installation_id,
136
+ account_id=event.account.id,
137
+ account_login=event.account.login,
138
+ account_type=event.account.type,
139
+ repository_selection=event.repository_selection,
140
+ state=InstallationState.ACTIVE,
141
+ permissions=event.permissions,
142
+ created_at=existing.created_at if existing else now,
143
+ updated_at=now,
144
+ )
145
+ installation_id = event.installation_id
146
+ deleted = existing is not None and existing.state is InstallationState.DELETED
147
+ if deleted and event.action is not InstallationAction.CREATED:
148
+ log.info("installation_event_after_removal_ignored", action=event.action.value)
149
+ return
150
+ before = existing.state if existing else None
151
+ if event.action is InstallationAction.CREATED:
152
+ self._store.replace_repositories(event.installation_id, event.repositories, now)
153
+ self._apply(
154
+ record,
155
+ before,
156
+ AuditEventType.INSTALLATION_CREATED,
157
+ actor=GITHUB_ACTOR,
158
+ installation_id=installation_id,
159
+ account_id=event.account.id,
160
+ account_type=event.account.type.value,
161
+ repository_selection=event.repository_selection,
162
+ repositories=len(event.repositories),
163
+ )
164
+ self._discovered(event.account.id, installation_id, event.repositories)
165
+ elif event.action is InstallationAction.DELETED:
166
+ repositories = len(self._store.list_repositories(installation_id))
167
+ self._apply(
168
+ record.model_copy(update={"state": InstallationState.DELETED}),
169
+ before,
170
+ AuditEventType.INSTALLATION_REMOVED,
171
+ affected=repositories,
172
+ actor=GITHUB_ACTOR,
173
+ installation_id=installation_id,
174
+ )
175
+ self._tokens.invalidate(event.installation_id)
176
+ self._mirrors.remove_installation(event.installation_id)
177
+ elif event.action is InstallationAction.SUSPEND:
178
+ self._tokens.invalidate(event.installation_id)
179
+ self._apply(
180
+ record.model_copy(update={"state": InstallationState.SUSPENDED}),
181
+ before,
182
+ AuditEventType.INSTALLATION_SUSPENDED,
183
+ actor=GITHUB_ACTOR,
184
+ installation_id=installation_id,
185
+ )
186
+ elif event.action is InstallationAction.UNSUSPEND:
187
+ self._apply(
188
+ record,
189
+ before,
190
+ AuditEventType.INSTALLATION_UNSUSPENDED,
191
+ actor=GITHUB_ACTOR,
192
+ installation_id=installation_id,
193
+ )
194
+ else: # new_permissions_accepted
195
+ state = existing.state if existing else InstallationState.ACTIVE
196
+ self._tokens.invalidate(event.installation_id)
197
+ self._apply(
198
+ record.model_copy(update={"state": state}),
199
+ before,
200
+ AuditEventType.INSTALLATION_PERMISSIONS_UPDATED,
201
+ actor=GITHUB_ACTOR,
202
+ installation_id=installation_id,
203
+ )
204
+
205
+ def _apply(
206
+ self,
207
+ record: InstallationRecord,
208
+ before: InstallationState | None,
209
+ audit_type: AuditEventType,
210
+ *,
211
+ affected: int | None = None,
212
+ actor: Actor,
213
+ installation_id: int,
214
+ **data: Any,
215
+ ) -> None:
216
+ """Store the installation state, its audit event and any transition notification."""
217
+ now = self._now()
218
+ after = record.state
219
+ with self._store.transaction() as db:
220
+ self._store.upsert_installation_in(db, record)
221
+ if after is InstallationState.DELETED:
222
+ self._store.set_installation_state_in(db, installation_id, after, now)
223
+ repositories = affected
224
+ if repositories is None:
225
+ repositories = int(
226
+ db.execute(
227
+ "SELECT COUNT(*) AS n FROM installation_repositories "
228
+ "WHERE installation_id = ?",
229
+ (installation_id,),
230
+ ).fetchone()["n"]
231
+ )
232
+ notification = self._transition_notification(db, record, before, repositories)
233
+ audit = self._store.insert_audit_event(
234
+ db,
235
+ self._audit.build(
236
+ audit_type,
237
+ actor=actor,
238
+ installation_id=installation_id,
239
+ account_id=record.account_id,
240
+ **{k: v for k, v in data.items() if k != "account_id"},
241
+ ),
242
+ )
243
+ if notification is not None:
244
+ emit(db, notification, now)
245
+ self._audit.log_stored(audit)
246
+
247
+ @staticmethod
248
+ def _transition_notification(
249
+ db: sqlite3.Connection,
250
+ record: InstallationRecord,
251
+ before: InstallationState | None,
252
+ repositories: int,
253
+ ) -> NotificationEvent | None:
254
+ after = record.state
255
+ account = record.account_login
256
+ if before is InstallationState.ACTIVE and after in (
257
+ InstallationState.SUSPENDED,
258
+ InstallationState.DELETED,
259
+ ):
260
+ how = "uninstalled" if after is InstallationState.DELETED else "suspended"
261
+ return NotificationEvent(
262
+ type=NotificationType.INSTALLATION_DISCONNECTED,
263
+ account_id=record.account_id,
264
+ severity=Severity.CRITICAL,
265
+ installation_id=record.installation_id,
266
+ resource_type="installation",
267
+ resource_id=str(record.installation_id),
268
+ dedup_key=domain_key(
269
+ NotificationType.INSTALLATION_DISCONNECTED, record.installation_id
270
+ ),
271
+ title=f"CommitGuard GitHub installation disconnected: {account}",
272
+ body=(
273
+ f"The CommitGuard GitHub App was {how} for {account}. Affected repositories: "
274
+ f"{repositories}. GitHub enforcement: AT RISK - CommitGuard checks no longer "
275
+ "run, and required checks may block or stop protecting merges. Action: "
276
+ "reinstall or unsuspend the CommitGuard GitHub App."
277
+ ),
278
+ metadata={
279
+ "installation_id": record.installation_id,
280
+ "account": account,
281
+ "state": after.value,
282
+ "affected_repositories": repositories,
283
+ "enforcement": "at_risk",
284
+ },
285
+ )
286
+ reconnected = before is InstallationState.SUSPENDED and after is InstallationState.ACTIVE
287
+ if before is None and after is InstallationState.ACTIVE:
288
+ previous = db.execute(
289
+ "SELECT 1 FROM installations WHERE account_id = ? AND installation_id != ? "
290
+ "AND state = 'deleted' LIMIT 1",
291
+ (record.account_id, record.installation_id),
292
+ ).fetchone()
293
+ reconnected = previous is not None
294
+ if before is InstallationState.DELETED and after is InstallationState.ACTIVE:
295
+ reconnected = True
296
+ if not reconnected:
297
+ return None
298
+ return NotificationEvent(
299
+ type=NotificationType.INSTALLATION_RECONNECTED,
300
+ account_id=record.account_id,
301
+ severity=Severity.LOW,
302
+ installation_id=record.installation_id,
303
+ resource_type="installation",
304
+ resource_id=str(record.installation_id),
305
+ dedup_key=domain_key(NotificationType.INSTALLATION_RECONNECTED, record.account_id),
306
+ title=f"CommitGuard GitHub installation restored: {account}",
307
+ body=(
308
+ f"The CommitGuard GitHub App is available again for {account}. Affected "
309
+ f"repositories: {repositories}. GitHub enforcement: RESTORED for new events. "
310
+ "Commits pushed while disconnected were not checked by the App; re-run the "
311
+ "CommitGuard check on open pull requests."
312
+ ),
313
+ metadata={
314
+ "installation_id": record.installation_id,
315
+ "account": account,
316
+ "affected_repositories": repositories,
317
+ "enforcement": "restored",
318
+ },
319
+ )
320
+
321
+ def handle_repositories(self, event: InstallationRepositoriesEvent) -> None:
322
+ now = self._now()
323
+ existing = self._store.get_installation(event.installation_id)
324
+ if existing is not None and existing.state is InstallationState.DELETED:
325
+ return # late event for a removed installation
326
+ if existing is None:
327
+ self._store.upsert_installation(
328
+ InstallationRecord(
329
+ installation_id=event.installation_id,
330
+ account_id=event.account.id,
331
+ account_login=event.account.login,
332
+ account_type=event.account.type,
333
+ repository_selection=event.repository_selection,
334
+ state=InstallationState.ACTIVE,
335
+ created_at=now,
336
+ updated_at=now,
337
+ )
338
+ )
339
+ if event.action is RepositoriesAction.ADDED:
340
+ self._store.add_repositories(event.installation_id, event.added, now)
341
+ self._audit.record(
342
+ AuditEventType.REPOSITORIES_ADDED,
343
+ actor=GITHUB_ACTOR,
344
+ installation_id=event.installation_id,
345
+ repositories=len(event.added),
346
+ )
347
+ account_id = existing.account_id if existing is not None else event.account.id
348
+ self._discovered(account_id, event.installation_id, event.added)
349
+ else:
350
+ removed = [r.id for r in event.removed]
351
+ self._store.remove_repositories(event.installation_id, removed, now)
352
+ for repository_id in removed:
353
+ self._tokens.invalidate(event.installation_id, repository_id)
354
+ self._mirrors.remove_repository(event.installation_id, repository_id)
355
+ self._audit.record(
356
+ AuditEventType.REPOSITORIES_REMOVED,
357
+ actor=GITHUB_ACTOR,
358
+ installation_id=event.installation_id,
359
+ repositories=len(removed),
360
+ )
361
+
362
+ # -- authorization --------------------------------------------------- #
363
+ def denial_reason(self, installation_id: int) -> str | None:
364
+ """A reason to reject events for this installation without calling GitHub."""
365
+ record = self._store.get_installation(installation_id)
366
+ if record is None:
367
+ return None # unknown here (e.g. installed before this service ran): ask GitHub
368
+ if record.state is InstallationState.DELETED:
369
+ return "installation removed"
370
+ if record.state is InstallationState.SUSPENDED:
371
+ return "installation suspended"
372
+ return None
373
+
374
+ def ensure_installation_record(self, installation_id: int) -> InstallationRecord:
375
+ """The stored installation, fetched from GitHub when this service has not seen it.
376
+
377
+ An App installed before the service first ran has no ``installation``
378
+ webhook on record; the dashboard needs its account to attribute data to a
379
+ tenant, so the details are read with the App JWT and stored.
380
+ """
381
+ existing = self._store.get_installation(installation_id)
382
+ if existing is not None:
383
+ return existing
384
+ info = self._fetch_installation(installation_id)
385
+ record = self._record_from(info, None)
386
+ self._store.upsert_installation(record)
387
+ return record
388
+
389
+ def _fetch_installation(self, installation_id: int) -> InstallationInfo:
390
+ try:
391
+ return self._client.get_installation(self._tokens.app_jwt(), installation_id)
392
+ except GitHubUnauthorizedError:
393
+ raise AuthenticationError("GitHub rejected the App credentials") from None
394
+ except GitHubNotFoundError:
395
+ raise AuthorizationError("GitHub App installation not found") from None
396
+
397
+ def _record_from(
398
+ self, info: InstallationInfo, existing: InstallationRecord | None
399
+ ) -> InstallationRecord:
400
+ now = self._now()
401
+ state = InstallationState.SUSPENDED if info.suspended_at else InstallationState.ACTIVE
402
+ return InstallationRecord(
403
+ installation_id=info.id,
404
+ account_id=info.account.id,
405
+ account_login=info.account.login,
406
+ account_type=AccountType(info.account.type),
407
+ repository_selection=info.repository_selection,
408
+ state=state,
409
+ permissions=info.permissions,
410
+ created_at=existing.created_at if existing else now,
411
+ updated_at=now,
412
+ )
413
+
414
+ def sync_repositories(self, installation_id: int, actor: Actor) -> RepositorySync:
415
+ """Refresh an installation's details and repository list from GitHub.
416
+
417
+ Only the installation named by a caller that is already authorised for it
418
+ is contacted; the repository list comes from GitHub with an installation
419
+ token, never from the caller.
420
+ """
421
+ existing = self._store.get_installation(installation_id)
422
+ if existing is None or existing.state is InstallationState.DELETED:
423
+ raise AuthorizationError("GitHub App installation removed")
424
+ self._sync_status(installation_id, existing.account_id, "syncing")
425
+ try:
426
+ result = self._sync_repositories(installation_id, existing, actor)
427
+ except Exception as exc:
428
+ self._sync_status(
429
+ installation_id,
430
+ existing.account_id,
431
+ "failed",
432
+ error=f"synchronisation failed ({type(exc).__name__})",
433
+ )
434
+ raise
435
+ self._sync_status(
436
+ installation_id,
437
+ existing.account_id,
438
+ "healthy",
439
+ repositories=len(result.repositories),
440
+ added=len(result.added),
441
+ removed=len(result.removed),
442
+ )
443
+ return result
444
+
445
+ def _sync_status(
446
+ self,
447
+ installation_id: int,
448
+ account_id: int,
449
+ state: str,
450
+ *,
451
+ error: str | None = None,
452
+ repositories: int | None = None,
453
+ added: int | None = None,
454
+ removed: int | None = None,
455
+ ) -> None:
456
+ """Record synchronisation health (separate from "connected")."""
457
+ now = self._now().timestamp()
458
+ with self._store.transaction() as db:
459
+ db.execute(
460
+ "INSERT INTO installation_sync_status (installation_id, account_id, state, "
461
+ "started_at, completed_at, last_success_at, repositories, added, removed, error, "
462
+ "updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
463
+ "ON CONFLICT (installation_id) DO UPDATE SET state = excluded.state, "
464
+ "started_at = COALESCE(excluded.started_at, installation_sync_status.started_at), "
465
+ "completed_at = COALESCE(excluded.completed_at, "
466
+ "installation_sync_status.completed_at), last_success_at = "
467
+ "COALESCE(excluded.last_success_at, installation_sync_status.last_success_at), "
468
+ "repositories = COALESCE(excluded.repositories, "
469
+ "installation_sync_status.repositories), added = excluded.added, "
470
+ "removed = excluded.removed, error = excluded.error, "
471
+ "updated_at = excluded.updated_at",
472
+ (
473
+ installation_id,
474
+ account_id,
475
+ state,
476
+ now if state == "syncing" else None,
477
+ now if state != "syncing" else None,
478
+ now if state == "healthy" else None,
479
+ repositories,
480
+ added,
481
+ removed,
482
+ error,
483
+ now,
484
+ ),
485
+ )
486
+
487
+ def _sync_repositories(
488
+ self, installation_id: int, existing: InstallationRecord, actor: Actor
489
+ ) -> RepositorySync:
490
+ info = self._fetch_installation(installation_id)
491
+ if info.account.id != existing.account_id:
492
+ raise AuthorizationError("installation account mismatch")
493
+ record = self._record_from(info, existing)
494
+ self._store.upsert_installation(record)
495
+ if record.state is InstallationState.SUSPENDED:
496
+ self._tokens.invalidate(installation_id)
497
+ raise AuthorizationError("GitHub App installation suspended")
498
+ before = {r.id: r for r in self._store.list_repositories(installation_id)}
499
+ self._tokens.invalidate(installation_id, None)
500
+ token = self._tokens.token(installation_id, None)
501
+ try:
502
+ listed = self._client.list_installation_repositories(token.token)
503
+ finally:
504
+ self._tokens.invalidate(installation_id, None)
505
+ now = self._now()
506
+ repositories = tuple(info.ref for info in listed)
507
+ self._store.replace_repositories(installation_id, repositories, now)
508
+ for repository_info in listed:
509
+ self._store.set_repository_details(
510
+ installation_id,
511
+ repository_info.id,
512
+ default_branch=repository_info.default_branch,
513
+ private=repository_info.private,
514
+ archived=repository_info.archived,
515
+ )
516
+ after = {r.id: r for r in repositories}
517
+ added = tuple(r for rid, r in sorted(after.items()) if rid not in before)
518
+ removed = tuple(r for rid, r in sorted(before.items()) if rid not in after)
519
+ for repository in removed:
520
+ self._tokens.invalidate(installation_id, repository.id)
521
+ self._mirrors.remove_repository(installation_id, repository.id)
522
+ self._audit.record(
523
+ AuditEventType.REPOSITORIES_SYNCED,
524
+ actor=actor,
525
+ installation_id=installation_id,
526
+ repositories=len(repositories),
527
+ added=len(added),
528
+ removed=len(removed),
529
+ )
530
+ self._discovered(record.account_id, installation_id, repositories)
531
+ return RepositorySync(installation_id, repositories, added, removed, now)
532
+
533
+ def authorize(self, installation_id: int, repository: RepositoryRef) -> AuthorizedRepository:
534
+ reason = self.denial_reason(installation_id)
535
+ if reason is not None:
536
+ raise AuthorizationError(f"GitHub App {reason}")
537
+ try:
538
+ self.ensure_installation_record(installation_id)
539
+ except GitHubAPIError as exc:
540
+ log.warning("installation_details_unavailable", category=exc.category.value)
541
+ token = self._tokens.token(installation_id, repository.id)
542
+ try:
543
+ info = self._client.get_repository(token.token, repository.id)
544
+ except GitHubUnauthorizedError:
545
+ self._tokens.invalidate(installation_id, repository.id)
546
+ raise AuthenticationError("GitHub rejected the installation token") from None
547
+ except (GitHubNotFoundError, GitHubForbiddenError):
548
+ self._tokens.invalidate(installation_id, repository.id)
549
+ raise AuthorizationError("the installation cannot access this repository") from None
550
+ if info.id != repository.id:
551
+ raise AuthorizationError("repository identity mismatch")
552
+ canonical = info.ref
553
+ self._store.add_repositories(installation_id, [canonical], self._now())
554
+ self._store.set_repository_details(
555
+ installation_id,
556
+ canonical.id,
557
+ default_branch=info.default_branch,
558
+ private=info.private,
559
+ archived=info.archived,
560
+ )
561
+ return AuthorizedRepository(
562
+ installation_id=installation_id,
563
+ repository=canonical,
564
+ token=token,
565
+ default_branch=info.default_branch,
566
+ )
@@ -0,0 +1,19 @@
1
+ """Escaping of untrusted text for GitHub-rendered Markdown (job summaries, Check Runs)."""
2
+
3
+ from commitguard.security.sanitization import sanitize_for_terminal
4
+ from commitguard.security.secrets import redact
5
+
6
+
7
+ def escape_markdown(value: str, limit: int = 200) -> str:
8
+ """Escape untrusted text for a Markdown paragraph or table cell.
9
+
10
+ Control and bidi characters are made visible, credential-shaped strings
11
+ are redacted, Markdown syntax is backslash-escaped (links, images, emphasis,
12
+ headings cannot be injected) and HTML is entity-encoded.
13
+ """
14
+ text = sanitize_for_terminal(redact(value), max_length=limit)
15
+ for char in "\\`*_{}[]()#+-.!~": # Markdown first: entities below contain '#'
16
+ text = text.replace(char, "\\" + char)
17
+ for char, entity in (("&", "&amp;"), ("<", "&lt;"), (">", "&gt;"), ("|", "&#124;")):
18
+ text = text.replace(char, entity)
19
+ return text
@@ -0,0 +1,70 @@
1
+ """GitHub App permissions: what CommitGuard needs, and nothing more.
2
+
3
+ ================= ====== =====================================================
4
+ Permission Level Why
5
+ ================= ====== =====================================================
6
+ ``metadata`` read mandatory for every App; repository lookup by ID
7
+ ``contents`` read fetch commit objects (no file contents are kept) and
8
+ receive ``push`` events
9
+ ``pull_requests`` read receive ``pull_request`` events; confirm the PR head
10
+ before publishing a result
11
+ ``checks`` write create and update the CommitGuard Check Run; receive
12
+ ``check_run`` / ``check_suite`` re-run requests
13
+ ================= ====== =====================================================
14
+
15
+ Optional (requested only for the feature that needs it):
16
+
17
+ ================= ====== =====================================================
18
+ ``merge_queues`` read receive ``merge_group`` events, so CommitGuard can
19
+ validate the exact commit a merge queue tests
20
+ ================= ====== =====================================================
21
+
22
+ An installation without an optional permission keeps working; the feature is
23
+ reported as unavailable. Optional permissions are never requested for
24
+ installation tokens: scanning a merge group needs only ``contents: read``.
25
+
26
+ CommitGuard never needs write access to contents, administration, actions,
27
+ workflows or members: it detects and reports, it never modifies a repository.
28
+ Installation tokens are additionally *down-scoped* to exactly the required
29
+ permissions and to the single repository being scanned.
30
+ """
31
+
32
+ from collections.abc import Mapping
33
+ from types import MappingProxyType
34
+
35
+ REQUIRED_PERMISSIONS: Mapping[str, str] = MappingProxyType(
36
+ {"checks": "write", "contents": "read", "metadata": "read", "pull_requests": "read"}
37
+ )
38
+ OPTIONAL_PERMISSIONS: Mapping[str, str] = MappingProxyType({"merge_queues": "read"})
39
+ WEBHOOK_EVENTS = ("installation", "installation_repositories", "pull_request", "push")
40
+ #: Subscriptions for Phase 7 features: GitHub "Re-run" buttons and merge queues.
41
+ OPTIONAL_WEBHOOK_EVENTS = ("check_run", "check_suite", "merge_group")
42
+
43
+ _LEVELS = {"none": 0, "read": 1, "write": 2, "admin": 3}
44
+
45
+
46
+ def level_rank(level: str | None) -> int:
47
+ return _LEVELS.get(level or "none", 0)
48
+
49
+
50
+ def missing_permissions(
51
+ granted: Mapping[str, str], required: Mapping[str, str] = REQUIRED_PERMISSIONS
52
+ ) -> dict[str, str]:
53
+ """Required permissions that ``granted`` does not satisfy (name -> required level)."""
54
+ return {
55
+ name: level
56
+ for name, level in sorted(required.items())
57
+ if level_rank(granted.get(name)) < level_rank(level)
58
+ }
59
+
60
+
61
+ def excessive_permissions(
62
+ granted: Mapping[str, str], required: Mapping[str, str] = REQUIRED_PERMISSIONS
63
+ ) -> dict[str, str]:
64
+ """Granted permissions above what CommitGuard needs or can use (name -> granted level)."""
65
+ usable = {**OPTIONAL_PERMISSIONS, **required}
66
+ return {
67
+ name: level
68
+ for name, level in sorted(granted.items())
69
+ if level_rank(level) > level_rank(usable.get(name))
70
+ }