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,393 @@
1
+ """Write operations the dashboard can request.
2
+
3
+ Each command resolves its target inside the caller's access scope first, so a
4
+ resource from another tenant is "not found" before any permission question is
5
+ asked; then it checks the permission for that resource's account; then it
6
+ changes state and records an audit event in the same transaction.
7
+
8
+ None of these commands can change a security decision already made: they cannot
9
+ mark a violation resolved, turn a failed GitHub check into a success, or touch
10
+ Git history. Pausing monitoring and weakening policy are the only operations
11
+ that can reduce enforcement; both need explicit confirmation and a recent
12
+ sign-in.
13
+ """
14
+
15
+ import json
16
+ from collections.abc import Callable
17
+ from datetime import UTC, datetime
18
+
19
+ from commitguard.audit.models import Actor, AuditEventType
20
+ from commitguard.controlplane.access import Permission, Principal
21
+ from commitguard.controlplane.errors import (
22
+ ConfirmationRequiredError,
23
+ ConflictError,
24
+ InputValidationError,
25
+ NotFoundError,
26
+ PermissionDeniedError,
27
+ ReauthenticationRequiredError,
28
+ UpstreamUnavailableError,
29
+ )
30
+ from commitguard.controlplane.policies import REAUTHENTICATION_WINDOW
31
+ from commitguard.controlplane.queries import DashboardQueries
32
+ from commitguard.controlplane.results import clean_text
33
+ from commitguard.controlplane.views import SyncResult
34
+ from commitguard.core.result import Severity
35
+ from commitguard.exceptions.base import CommitGuardError
36
+ from commitguard.github.enforcement_status import EnforcementProbe
37
+ from commitguard.github.errors import AuthorizationError, GitHubAPIError
38
+ from commitguard.github.identifiers import RepositoryRef
39
+ from commitguard.github.installations import InstallationService
40
+ from commitguard.github.storage import ScanTrigger, SqliteStateStore
41
+ from commitguard.notifications.deduplication import domain_key
42
+ from commitguard.notifications.models import NotificationEvent, NotificationType
43
+ from commitguard.notifications.outbox import account_for_installation, emit
44
+ from commitguard.observability.logging import get_logger
45
+ from commitguard.services.audit import AuditService
46
+
47
+ log = get_logger(__name__)
48
+
49
+ MAX_NOTE_CHARS = 500
50
+
51
+
52
+ def _actor(principal: Principal) -> Actor:
53
+ return Actor.user(principal.user_id, principal.login)
54
+
55
+
56
+ def _note(value: object, field: str, *, required: bool = False) -> str | None:
57
+ if value is None or (isinstance(value, str) and not value.strip()):
58
+ if required:
59
+ raise InputValidationError("A reason is required.", field=field)
60
+ return None
61
+ if not isinstance(value, str) or len(value) > MAX_NOTE_CHARS:
62
+ raise InputValidationError(
63
+ f"{field} must be text of at most {MAX_NOTE_CHARS} characters", field=field
64
+ )
65
+ return clean_text(value.strip(), MAX_NOTE_CHARS)
66
+
67
+
68
+ class ControlPlaneCommands:
69
+ def __init__(
70
+ self,
71
+ store: SqliteStateStore,
72
+ queries: DashboardQueries,
73
+ audit: AuditService,
74
+ installations: InstallationService,
75
+ probe: EnforcementProbe,
76
+ *,
77
+ enqueue: Callable[[str], bool],
78
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
79
+ ) -> None:
80
+ self._store = store
81
+ self._queries = queries
82
+ self._audit = audit
83
+ self._installations = installations
84
+ self._probe = probe
85
+ self._enqueue = enqueue
86
+ self._now = now
87
+
88
+ # ------------------------------------------------------------------ #
89
+ # Violations
90
+ # ------------------------------------------------------------------ #
91
+ def acknowledge_violation(self, principal: Principal, violation_id: str, note: object) -> None:
92
+ scope = principal.scope(Permission.VIOLATIONS_READ)
93
+ row = self._queries.violation_row(scope, violation_id)
94
+ if row is None:
95
+ raise NotFoundError()
96
+ account_id = int(row["account_id"])
97
+ if not principal.can(Permission.VIOLATIONS_MANAGE, account_id):
98
+ raise PermissionDeniedError()
99
+ text = _note(note, "note")
100
+ now = self._now().timestamp()
101
+ with self._store.transaction() as db:
102
+ cursor = db.execute(
103
+ "UPDATE violations SET acknowledged_at = ?, acknowledged_by_id = ?, "
104
+ "acknowledged_by_login = ?, acknowledgement_note = ?, updated_at = ? "
105
+ "WHERE violation_id = ? AND status = 'open'",
106
+ (now, principal.user_id, principal.login, text, now, violation_id),
107
+ )
108
+ if cursor.rowcount != 1:
109
+ raise ConflictError("Only open violations can be acknowledged.")
110
+ event = self._store.insert_audit_event(
111
+ db,
112
+ self._audit.build(
113
+ AuditEventType.VIOLATION_ACKNOWLEDGED,
114
+ actor=_actor(principal),
115
+ installation_id=row["installation_id"],
116
+ repository_id=row["repository_id"],
117
+ violation=violation_id,
118
+ rule=row["rule_id"],
119
+ note=text,
120
+ ),
121
+ )
122
+ self._audit.log_stored(event)
123
+
124
+ def remove_acknowledgement(self, principal: Principal, violation_id: str) -> None:
125
+ scope = principal.scope(Permission.VIOLATIONS_READ)
126
+ row = self._queries.violation_row(scope, violation_id)
127
+ if row is None:
128
+ raise NotFoundError()
129
+ if not principal.can(Permission.VIOLATIONS_MANAGE, int(row["account_id"])):
130
+ raise PermissionDeniedError()
131
+ with self._store.transaction() as db:
132
+ cursor = db.execute(
133
+ "UPDATE violations SET acknowledged_at = NULL, acknowledged_by_id = NULL, "
134
+ "acknowledged_by_login = NULL, acknowledgement_note = NULL, updated_at = ? "
135
+ "WHERE violation_id = ? AND acknowledged_at IS NOT NULL",
136
+ (self._now().timestamp(), violation_id),
137
+ )
138
+ if cursor.rowcount != 1:
139
+ raise ConflictError("This violation is not acknowledged.")
140
+ event = self._store.insert_audit_event(
141
+ db,
142
+ self._audit.build(
143
+ AuditEventType.VIOLATION_ACKNOWLEDGEMENT_REMOVED,
144
+ actor=_actor(principal),
145
+ installation_id=row["installation_id"],
146
+ repository_id=row["repository_id"],
147
+ violation=violation_id,
148
+ rule=row["rule_id"],
149
+ ),
150
+ )
151
+ self._audit.log_stored(event)
152
+
153
+ # ------------------------------------------------------------------ #
154
+ # Repositories
155
+ # ------------------------------------------------------------------ #
156
+ def _manageable_repository(self, principal: Principal, repository_id: int): # type: ignore[no-untyped-def]
157
+ row = self._queries.repository_ref(
158
+ principal.scope(Permission.REPOSITORIES_READ), repository_id
159
+ )
160
+ if row is None:
161
+ raise NotFoundError()
162
+ if not principal.can(Permission.REPOSITORIES_MANAGE, int(row["account_id"])):
163
+ raise PermissionDeniedError()
164
+ return row
165
+
166
+ def set_monitoring(
167
+ self,
168
+ principal: Principal,
169
+ repository_id: int,
170
+ *,
171
+ enabled: object,
172
+ confirm: object,
173
+ reason: object,
174
+ ) -> None:
175
+ if not isinstance(enabled, bool):
176
+ raise InputValidationError("enabled must be true or false", field="enabled")
177
+ row = self._manageable_repository(principal, repository_id)
178
+ installation_id = int(row["installation_id"])
179
+ now = self._now()
180
+ text = _note(reason, "reason", required=not enabled)
181
+ if not enabled:
182
+ if confirm is not True:
183
+ raise ConfirmationRequiredError(
184
+ "Pausing monitoring stops CommitGuard checks for this repository and must be "
185
+ "confirmed."
186
+ )
187
+ if now - principal.authenticated_at > REAUTHENTICATION_WINDOW:
188
+ raise ReauthenticationRequiredError()
189
+ with self._store.transaction() as db:
190
+ current = db.execute(
191
+ "SELECT monitoring_enabled FROM repository_settings WHERE installation_id = ? "
192
+ "AND repository_id = ?",
193
+ (installation_id, repository_id),
194
+ ).fetchone()
195
+ if (current is None and enabled) or (
196
+ current is not None and bool(current["monitoring_enabled"]) == enabled
197
+ ):
198
+ raise ConflictError(
199
+ "Monitoring is already " + ("enabled." if enabled else "paused.")
200
+ )
201
+ db.execute(
202
+ "INSERT INTO repository_settings (installation_id, repository_id, "
203
+ "monitoring_enabled, updated_at, updated_by_login) VALUES (?, ?, ?, ?, ?) "
204
+ "ON CONFLICT (installation_id, repository_id) DO UPDATE SET "
205
+ "monitoring_enabled = excluded.monitoring_enabled, "
206
+ "updated_at = excluded.updated_at, updated_by_login = excluded.updated_by_login",
207
+ (installation_id, repository_id, int(enabled), now.timestamp(), principal.login),
208
+ )
209
+ if not enabled:
210
+ db.execute(
211
+ "UPDATE scan_jobs SET state = 'cancelled', message = 'monitoring paused', "
212
+ "updated_at = ?, completed_at = ? WHERE installation_id = ? "
213
+ "AND repository_id = ? AND state = 'queued'",
214
+ (now.timestamp(), now.timestamp(), installation_id, repository_id),
215
+ )
216
+ event = self._store.insert_audit_event(
217
+ db,
218
+ self._audit.build(
219
+ AuditEventType.REPOSITORY_MONITORING_ENABLED
220
+ if enabled
221
+ else AuditEventType.REPOSITORY_MONITORING_DISABLED,
222
+ actor=_actor(principal),
223
+ installation_id=installation_id,
224
+ repository_id=repository_id,
225
+ repository=f"{row['owner']}/{row['name']}",
226
+ reason=text,
227
+ ),
228
+ )
229
+ self._audit.log_stored(event)
230
+
231
+ def refresh_enforcement(self, principal: Principal, repository_id: int) -> None:
232
+ row = self._manageable_repository(principal, repository_id)
233
+ installation_id = int(row["installation_id"])
234
+ repository = RepositoryRef(id=repository_id, owner=row["owner"], name=row["name"])
235
+ try:
236
+ authorized = self._installations.authorize(installation_id, repository)
237
+ evidence = self._probe.probe(
238
+ authorized.token.token, authorized.repository, authorized.default_branch
239
+ )
240
+ except AuthorizationError as exc:
241
+ raise ConflictError(f"GitHub denied access: {exc}") from None
242
+ except GitHubAPIError:
243
+ raise UpstreamUnavailableError(
244
+ "GitHub could not be reached. Try again later."
245
+ ) from None
246
+ except CommitGuardError as exc:
247
+ log.warning("enforcement_probe_failed", error_type=type(exc).__name__)
248
+ raise UpstreamUnavailableError("The enforcement status could not be checked.") from None
249
+ with self._store.transaction() as db:
250
+ previous = db.execute(
251
+ "SELECT branch_protection FROM enforcement_status WHERE installation_id = ? "
252
+ "AND repository_id = ?",
253
+ (installation_id, repository_id),
254
+ ).fetchone()
255
+ db.execute(
256
+ "INSERT INTO enforcement_status (installation_id, repository_id, checked_at, "
257
+ "branch, actions, actions_detail, branch_protection, required_checks, "
258
+ "branch_protection_detail, merge_queue, merge_queue_detail) "
259
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
260
+ "ON CONFLICT (installation_id, repository_id) DO UPDATE SET "
261
+ "checked_at = excluded.checked_at, branch = excluded.branch, "
262
+ "actions = excluded.actions, actions_detail = excluded.actions_detail, "
263
+ "branch_protection = excluded.branch_protection, "
264
+ "required_checks = excluded.required_checks, "
265
+ "branch_protection_detail = excluded.branch_protection_detail, "
266
+ "merge_queue = excluded.merge_queue, "
267
+ "merge_queue_detail = excluded.merge_queue_detail",
268
+ (
269
+ installation_id,
270
+ repository_id,
271
+ evidence.checked_at.timestamp(),
272
+ clean_text(evidence.branch, 256) if evidence.branch else None,
273
+ evidence.actions,
274
+ clean_text(evidence.actions_detail, 500),
275
+ evidence.branch_protection,
276
+ json.dumps([clean_text(c, 200) for c in evidence.required_checks]),
277
+ clean_text(evidence.branch_protection_detail, 500),
278
+ evidence.merge_queue,
279
+ clean_text(evidence.merge_queue_detail, 500),
280
+ ),
281
+ )
282
+ event = self._store.insert_audit_event(
283
+ db,
284
+ self._audit.build(
285
+ AuditEventType.ENFORCEMENT_STATUS_CHECKED,
286
+ actor=_actor(principal),
287
+ installation_id=installation_id,
288
+ repository_id=repository_id,
289
+ repository=authorized.repository.full_name,
290
+ actions=evidence.actions,
291
+ branch_protection=evidence.branch_protection,
292
+ merge_queue=evidence.merge_queue,
293
+ ),
294
+ )
295
+ was_required = previous is not None and previous["branch_protection"] == "required"
296
+ account_id = account_for_installation(db, installation_id)
297
+ if was_required and evidence.branch_protection != "required" and account_id:
298
+ emit(
299
+ db,
300
+ NotificationEvent(
301
+ type=NotificationType.REPOSITORY_UNPROTECTED,
302
+ account_id=account_id,
303
+ severity=Severity.HIGH,
304
+ installation_id=installation_id,
305
+ repository_id=repository_id,
306
+ resource_type="repository",
307
+ resource_id=str(repository_id),
308
+ dedup_key=domain_key(
309
+ NotificationType.REPOSITORY_UNPROTECTED, account_id, repository_id
310
+ ),
311
+ title=f"Repository no longer protected: {authorized.repository.full_name}",
312
+ body=(
313
+ f"GitHub no longer requires a CommitGuard check on "
314
+ f"{authorized.repository.full_name}"
315
+ f"{' (' + evidence.branch + ')' if evidence.branch else ''}. "
316
+ "Failing CommitGuard checks no longer block merges. "
317
+ f"{clean_text(evidence.branch_protection_detail, 300)}"
318
+ ),
319
+ metadata={"branch_protection": evidence.branch_protection},
320
+ ),
321
+ self._now(),
322
+ )
323
+ self._audit.log_stored(event)
324
+
325
+ # ------------------------------------------------------------------ #
326
+ # GitHub installations
327
+ # ------------------------------------------------------------------ #
328
+ def sync_installation(self, principal: Principal, installation_id: int) -> SyncResult:
329
+ scope = principal.scope(Permission.REPOSITORIES_READ)
330
+ if installation_id not in scope.installation_ids:
331
+ raise NotFoundError()
332
+ account_id = principal.installations[installation_id]
333
+ if not principal.can(Permission.GITHUB_MANAGE, account_id):
334
+ raise PermissionDeniedError()
335
+ try:
336
+ sync = self._installations.sync_repositories(installation_id, _actor(principal))
337
+ except AuthorizationError as exc:
338
+ raise ConflictError(f"GitHub denied access: {exc}") from None
339
+ except GitHubAPIError:
340
+ raise UpstreamUnavailableError(
341
+ "GitHub could not be reached. Try again later."
342
+ ) from None
343
+ return SyncResult(
344
+ installation_id=installation_id,
345
+ repositories=len(sync.repositories),
346
+ added=tuple(r.full_name for r in sync.added),
347
+ removed=tuple(r.full_name for r in sync.removed),
348
+ synced_at=sync.synced_at,
349
+ )
350
+
351
+ # ------------------------------------------------------------------ #
352
+ # Scans
353
+ # ------------------------------------------------------------------ #
354
+ def request_rescan(self, principal: Principal, scan_id: str) -> str:
355
+ """Queue a new execution of exactly the commits a stored scan covered.
356
+
357
+ The repository, commits and event come from the stored scan - never from
358
+ the request - and the new execution goes through the same worker path as a
359
+ webhook (authorization against GitHub, current effective policy, check
360
+ ownership). It is recorded as execution N+1 of the same scan with trigger
361
+ ``manual``; earlier executions are unchanged. Callers cannot choose a
362
+ historical policy version.
363
+ """
364
+ row = self._queries.scan_row_for_command(principal.scope(Permission.SCANS_READ), scan_id)
365
+ if row is None:
366
+ raise NotFoundError()
367
+ allowed, reason = self._queries.rescan_eligibility(row, principal)
368
+ if not allowed:
369
+ if not principal.can(Permission.SCANS_TRIGGER, int(row["account_id"] or 0)):
370
+ raise PermissionDeniedError()
371
+ raise ConflictError(reason or "This scan cannot be repeated.")
372
+ job = self._store.get_job(scan_id)
373
+ if job is None: # pragma: no cover - the row was just read
374
+ raise NotFoundError()
375
+ new_job, created = self._store.create_execution(
376
+ job, trigger=ScanTrigger.MANUAL, now=self._now(), requested_by=principal.login
377
+ )
378
+ if not created:
379
+ raise ConflictError("A scan of these commits is already queued or running.")
380
+ self._audit.record(
381
+ AuditEventType.SCAN_REQUESTED,
382
+ actor=_actor(principal),
383
+ installation_id=job.installation_id,
384
+ repository_id=job.repository.id,
385
+ repository=job.repository.full_name,
386
+ head_sha=job.head_sha,
387
+ job=new_job.job_id,
388
+ previous_scan=scan_id,
389
+ execution=new_job.execution,
390
+ trigger=ScanTrigger.MANUAL.value,
391
+ )
392
+ self._enqueue(new_job.job_id) # if the queue is full, recovery picks it up
393
+ return new_job.job_id
@@ -0,0 +1,88 @@
1
+ """Control plane errors. The API layer maps each to one HTTP status and error code.
2
+
3
+ Messages are written for the person using the dashboard and never contain
4
+ secrets, SQL, stack traces or data from another tenant.
5
+ """
6
+
7
+ from commitguard.exceptions.base import CommitGuardError
8
+
9
+
10
+ class ControlPlaneError(CommitGuardError):
11
+ code = "INTERNAL_ERROR"
12
+ status = 500
13
+
14
+
15
+ class NotFoundError(ControlPlaneError):
16
+ """Missing, or belongs to a tenant the caller cannot see (never distinguished)."""
17
+
18
+ code = "NOT_FOUND"
19
+ status = 404
20
+
21
+ def __init__(self, message: str = "The requested resource was not found.") -> None:
22
+ super().__init__(message)
23
+
24
+
25
+ class PermissionDeniedError(ControlPlaneError):
26
+ """The caller can see the resource but their role does not allow the operation."""
27
+
28
+ code = "FORBIDDEN"
29
+ status = 403
30
+
31
+ def __init__(self, message: str = "You do not have permission to perform this action.") -> None:
32
+ super().__init__(message)
33
+
34
+
35
+ class InputValidationError(ControlPlaneError):
36
+ code = "VALIDATION_ERROR"
37
+ status = 400
38
+
39
+ def __init__(self, message: str, *, field: str | None = None) -> None:
40
+ super().__init__(message)
41
+ self.field = field
42
+
43
+
44
+ class ConflictError(ControlPlaneError):
45
+ code = "CONFLICT"
46
+ status = 409
47
+
48
+
49
+ class PolicyIntegrityError(ControlPlaneError):
50
+ """A stored policy version does not match its fingerprint or is not a valid policy."""
51
+
52
+ code = "POLICY_VERSION_INVALID"
53
+ status = 422
54
+
55
+
56
+ class ConfirmationRequiredError(ControlPlaneError):
57
+ """A change weakens enforcement and was not explicitly confirmed."""
58
+
59
+ code = "CONFIRMATION_REQUIRED"
60
+ status = 409
61
+
62
+ def __init__(self, message: str, *, changes: tuple[str, ...] = ()) -> None:
63
+ super().__init__(message)
64
+ self.changes = changes # what the change relaxes, one entry per control
65
+
66
+
67
+ class ApprovalRequiredError(ControlPlaneError):
68
+ """The organization requires policy changes to be approved before publication."""
69
+
70
+ code = "APPROVAL_REQUIRED"
71
+ status = 409
72
+
73
+
74
+ class ReauthenticationRequiredError(ControlPlaneError):
75
+ code = "REAUTHENTICATION_REQUIRED"
76
+ status = 401
77
+
78
+ def __init__(
79
+ self, message: str = "Sign in again to confirm your identity before this change."
80
+ ) -> None:
81
+ super().__init__(message)
82
+
83
+
84
+ class UpstreamUnavailableError(ControlPlaneError):
85
+ """GitHub could not be reached or refused the request."""
86
+
87
+ code = "GITHUB_UNAVAILABLE"
88
+ status = 502