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,861 @@
1
+ """Policy exceptions: scoped, time-limited, approved, and kept as history.
2
+
3
+ An exception lowers the action of **one rule** within **one explicit scope** -
4
+ a repository, a repository group, or the whole organization - to ``warn`` or
5
+ ``allow`` until it expires. It is the only way below a mandatory requirement,
6
+ which is why it is bounded on every side:
7
+
8
+ * **Scope is explicit.** A repository exception names a repository of the same
9
+ organization that the requester can see; a group exception names a group of
10
+ the organization. An exception for one repository can never apply to another.
11
+ * **Exceptions expire.** ``expires_at`` is required and at most
12
+ ``exception_max_days`` ahead. A permanent exception needs the organization
13
+ setting ``allow_permanent_exceptions``, ``exceptions:approve`` for the
14
+ requester, a justification, *and* approval by someone else.
15
+ * **Approval.** An exception needs approval when the rule's severity is at or
16
+ above ``exception_approval_min_severity`` (``high`` by default: every bundled
17
+ AI attribution rule), when it covers a group or the organization, or when it is
18
+ permanent. The approver needs ``exceptions:approve`` and cannot be the
19
+ requester. Otherwise the exception is active immediately.
20
+ * **Lifecycle.** ``requested`` -> ``active`` -> ``expired`` / ``revoked``;
21
+ ``requested`` -> ``rejected`` / ``cancelled``. Rows are never deleted (a
22
+ database trigger refuses it); at most one open (requested or active) exception
23
+ exists per rule and scope.
24
+
25
+ Every transition is audited, invalidates the effective policy of exactly the
26
+ repositories in scope (in the same transaction), and notifies through the
27
+ notification outbox: approval requests, approvals, expiry warnings (once per
28
+ configured threshold) and ends (expired or revoked). The maintenance loop runs
29
+ :meth:`PolicyExceptionService.expire_due` and :meth:`warn_expiring`.
30
+
31
+ What an exception never does: remove detection (findings are still recorded,
32
+ with the exception that lowered them), change historical scan results, or
33
+ resolve violations.
34
+ """
35
+
36
+ import json
37
+ import sqlite3
38
+ from collections.abc import Callable, Sequence
39
+ from datetime import UTC, datetime, timedelta
40
+
41
+ from pydantic import BaseModel, ConfigDict
42
+
43
+ from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEvent, AuditEventType
44
+ from commitguard.controlplane.access import Permission, Principal
45
+ from commitguard.controlplane.errors import (
46
+ ConflictError,
47
+ InputValidationError,
48
+ NotFoundError,
49
+ PermissionDeniedError,
50
+ )
51
+ from commitguard.controlplane.pagination import parse_timestamp
52
+ from commitguard.controlplane.rules import CATALOG_BY_ID
53
+ from commitguard.core.decision import Action
54
+ from commitguard.core.result import Severity
55
+ from commitguard.github.storage import SqliteStateStore
56
+ from commitguard.governance.cache import invalidate_scope
57
+ from commitguard.governance.common import (
58
+ MAX_REASON_CHARS,
59
+ account_repositories,
60
+ dt,
61
+ is_hex_id,
62
+ json_list,
63
+ new_id,
64
+ req_dt,
65
+ require,
66
+ text,
67
+ ts,
68
+ visible_repository_ids,
69
+ )
70
+ from commitguard.governance.settings import load_settings
71
+ from commitguard.notifications.deduplication import domain_key
72
+ from commitguard.notifications.models import NotificationEvent, NotificationType
73
+ from commitguard.notifications.outbox import emit
74
+ from commitguard.policies.defaults import DEFAULT_POLICIES
75
+ from commitguard.policies.governance import ExceptionGrant, ExceptionScope
76
+ from commitguard.services.audit import AuditService
77
+
78
+ EXPIRY_BATCH = 200
79
+ OPEN_STATUSES = ("requested", "active")
80
+ STATUSES = ("requested", "active", "rejected", "cancelled", "revoked", "expired")
81
+ EXPIRING_SOON = timedelta(days=7)
82
+
83
+
84
+ class ExceptionScopeView(BaseModel):
85
+ model_config = ConfigDict(frozen=True, extra="forbid")
86
+
87
+ type: ExceptionScope
88
+ id: str
89
+ label: str
90
+
91
+
92
+ class PolicyExceptionView(BaseModel):
93
+ model_config = ConfigDict(frozen=True, extra="forbid")
94
+
95
+ id: str
96
+ organization_id: int
97
+ rule_id: str
98
+ rule_name: str
99
+ severity: Severity
100
+ scope: ExceptionScopeView
101
+ action: Action
102
+ reason: str
103
+ status: str
104
+ requires_approval: bool
105
+ permanent: bool
106
+ expires_at: datetime | None
107
+ expiring_soon: bool
108
+ requested_at: datetime
109
+ requested_by: str | None
110
+ decided_at: datetime | None
111
+ decided_by: str | None
112
+ decision_note: str | None
113
+ activated_at: datetime | None
114
+ revoked_at: datetime | None
115
+ revoked_by: str | None
116
+ revoke_reason: str | None
117
+ expired_at: datetime | None
118
+ can_approve: bool
119
+ can_revoke: bool
120
+ can_cancel: bool
121
+
122
+
123
+ def rule_severity(rule_id: str) -> Severity:
124
+ entry = CATALOG_BY_ID.get(rule_id)
125
+ return entry.severity if entry else Severity.HIGH
126
+
127
+
128
+ def scope_label(
129
+ db: sqlite3.Connection | SqliteStateStore, account_id: int, scope: str, scope_id: str
130
+ ) -> str | None:
131
+ if scope == "organization":
132
+ return "Organization"
133
+ if scope == "group":
134
+ sql = "SELECT name FROM repository_groups WHERE group_id = ? AND account_id = ?"
135
+ params: tuple[object, ...] = (scope_id, int(account_id))
136
+ if isinstance(db, sqlite3.Connection):
137
+ row = db.execute(sql, params).fetchone()
138
+ else:
139
+ rows = db.query(sql, params)
140
+ row = rows[0] if rows else None
141
+ return f"Group {row['name']}" if row else None
142
+ if not scope_id.isdigit():
143
+ return None
144
+ repositories = account_repositories(db, account_id)
145
+ repository = repositories.get(int(scope_id))
146
+ return repository.full_name if repository else None
147
+
148
+
149
+ def active_grants(
150
+ db: sqlite3.Connection | SqliteStateStore,
151
+ account_id: int,
152
+ repository_id: int,
153
+ group_ids: Sequence[str],
154
+ now: datetime,
155
+ ) -> list[ExceptionGrant]:
156
+ """Active, unexpired exceptions that apply to one repository."""
157
+ sql = (
158
+ "SELECT e.exception_id, e.rule_id, e.action, e.scope_type, e.scope_id, e.expires_at, "
159
+ "e.permanent, (SELECT name FROM repository_groups g WHERE g.group_id = e.scope_id) "
160
+ "AS group_name FROM policy_exceptions e WHERE e.account_id = ? AND e.status = 'active' "
161
+ "AND (e.permanent = 1 OR e.expires_at > ?) AND (e.scope_type = 'organization' OR "
162
+ "(e.scope_type = 'repository' AND e.scope_id = ?) OR (e.scope_type = 'group' AND "
163
+ "e.scope_id IN (SELECT value FROM json_each(?)))) ORDER BY e.exception_id LIMIT 256"
164
+ )
165
+ params = (int(account_id), ts(now), str(int(repository_id)), json.dumps(list(group_ids)))
166
+ if isinstance(db, sqlite3.Connection):
167
+ rows = db.execute(sql, params).fetchall()
168
+ else:
169
+ rows = db.query(sql, params)
170
+ grants = []
171
+ for row in rows:
172
+ scope = ExceptionScope(row["scope_type"])
173
+ label = {
174
+ ExceptionScope.ORGANIZATION: "organization",
175
+ ExceptionScope.GROUP: f"group {row['group_name'] or row['scope_id']}",
176
+ ExceptionScope.REPOSITORY: "repository",
177
+ }[scope]
178
+ grants.append(
179
+ ExceptionGrant(
180
+ exception_id=row["exception_id"],
181
+ rule_id=row["rule_id"],
182
+ action=Action(row["action"]),
183
+ scope=scope,
184
+ scope_label=label,
185
+ expires_at=None if row["permanent"] else dt(row["expires_at"]),
186
+ )
187
+ )
188
+ return grants
189
+
190
+
191
+ class PolicyExceptionService:
192
+ def __init__(
193
+ self,
194
+ store: SqliteStateStore,
195
+ audit: AuditService,
196
+ *,
197
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
198
+ ) -> None:
199
+ self._store = store
200
+ self._audit = audit
201
+ self._now = now
202
+
203
+ # -- views ------------------------------------------------------------ #
204
+ def _view(self, row: sqlite3.Row, principal: Principal | None) -> PolicyExceptionView:
205
+ account_id = int(row["account_id"])
206
+ now = self._now()
207
+ expires = dt(row["expires_at"])
208
+ status = str(row["status"])
209
+ label = scope_label(self._store, account_id, row["scope_type"], row["scope_id"])
210
+ requester = row["requested_by_id"]
211
+ can_approve = bool(
212
+ principal is not None
213
+ and status == "requested"
214
+ and principal.can(Permission.EXCEPTIONS_APPROVE, account_id)
215
+ and requester != principal.user_id
216
+ )
217
+ return PolicyExceptionView(
218
+ id=row["exception_id"],
219
+ organization_id=account_id,
220
+ rule_id=row["rule_id"],
221
+ rule_name=CATALOG_BY_ID[row["rule_id"]].name
222
+ if row["rule_id"] in CATALOG_BY_ID
223
+ else row["rule_id"],
224
+ severity=Severity(row["severity"]),
225
+ scope=ExceptionScopeView(
226
+ type=ExceptionScope(row["scope_type"]),
227
+ id=row["scope_id"],
228
+ label=label or "(removed)",
229
+ ),
230
+ action=Action(row["action"]),
231
+ reason=row["reason"],
232
+ status=status,
233
+ requires_approval=bool(row["requires_approval"]),
234
+ permanent=bool(row["permanent"]),
235
+ expires_at=expires,
236
+ expiring_soon=bool(
237
+ status == "active" and expires is not None and expires - now <= EXPIRING_SOON
238
+ ),
239
+ requested_at=req_dt(row["requested_at"]),
240
+ requested_by=row["requested_by_login"],
241
+ decided_at=dt(row["decided_at"]),
242
+ decided_by=row["decided_by_login"],
243
+ decision_note=row["decision_note"],
244
+ activated_at=dt(row["activated_at"]),
245
+ revoked_at=dt(row["revoked_at"]),
246
+ revoked_by=row["revoked_by_login"],
247
+ revoke_reason=row["revoke_reason"],
248
+ expired_at=dt(row["expired_at"]),
249
+ can_approve=can_approve,
250
+ can_revoke=bool(
251
+ principal is not None
252
+ and status == "active"
253
+ and principal.can(Permission.EXCEPTIONS_REVOKE, account_id)
254
+ ),
255
+ can_cancel=bool(
256
+ principal is not None
257
+ and status == "requested"
258
+ and (
259
+ requester == principal.user_id
260
+ or principal.can(Permission.EXCEPTIONS_REVOKE, account_id)
261
+ )
262
+ ),
263
+ )
264
+
265
+ def _visible(self, principal: Principal, row: sqlite3.Row, visible: set[int]) -> bool:
266
+ if row["scope_type"] != "repository":
267
+ return True
268
+ return row["scope_id"].isdigit() and int(row["scope_id"]) in visible
269
+
270
+ def list(
271
+ self,
272
+ principal: Principal,
273
+ account_id: int,
274
+ *,
275
+ status: str | None = None,
276
+ rule_id: str | None = None,
277
+ repository_id: int | None = None,
278
+ group_id: str | None = None,
279
+ limit: int = 100,
280
+ offset: int = 0,
281
+ ) -> tuple[list[PolicyExceptionView], bool]:
282
+ require(principal, Permission.EXCEPTIONS_READ, account_id)
283
+ if status is not None and status not in STATUSES:
284
+ raise InputValidationError("unknown exception status", field="status")
285
+ if rule_id is not None and rule_id not in DEFAULT_POLICIES:
286
+ raise InputValidationError("unknown rule", field="rule")
287
+ if group_id is not None and not is_hex_id(group_id):
288
+ raise InputValidationError("group must be a group ID", field="group")
289
+ repository_scope = str(repository_id) if repository_id is not None else None
290
+ rows = self._store.query(
291
+ "SELECT * FROM policy_exceptions WHERE account_id = ? "
292
+ "AND (? IS NULL OR status = ?) AND (? IS NULL OR rule_id = ?) "
293
+ "AND (? IS NULL OR (scope_type = 'repository' AND scope_id = ?)) "
294
+ "AND (? IS NULL OR (scope_type = 'group' AND scope_id = ?)) "
295
+ "ORDER BY CASE status WHEN 'requested' THEN 0 WHEN 'active' THEN 1 ELSE 2 END, "
296
+ "requested_at DESC LIMIT 5000",
297
+ (
298
+ account_id,
299
+ status,
300
+ status,
301
+ rule_id,
302
+ rule_id,
303
+ repository_scope,
304
+ repository_scope,
305
+ group_id,
306
+ group_id,
307
+ ),
308
+ )
309
+ visible = visible_repository_ids(self._store, principal, account_id)
310
+ shown = [r for r in rows if self._visible(principal, r, visible)]
311
+ page = shown[offset : offset + limit]
312
+ return [self._view(r, principal) for r in page], len(shown) > offset + limit
313
+
314
+ def _row(self, exception_id: str) -> sqlite3.Row:
315
+ if not is_hex_id(exception_id):
316
+ raise NotFoundError()
317
+ rows = self._store.query(
318
+ "SELECT * FROM policy_exceptions WHERE exception_id = ?", (exception_id,)
319
+ )
320
+ if not rows:
321
+ raise NotFoundError()
322
+ return rows[0]
323
+
324
+ def _authorized_row(
325
+ self, principal: Principal, exception_id: str, permission: Permission
326
+ ) -> sqlite3.Row:
327
+ row = self._row(exception_id)
328
+ account_id = int(row["account_id"])
329
+ require(principal, Permission.EXCEPTIONS_READ, account_id)
330
+ visible = visible_repository_ids(self._store, principal, account_id)
331
+ if not self._visible(principal, row, visible):
332
+ raise NotFoundError()
333
+ if not principal.can(permission, account_id):
334
+ raise PermissionDeniedError()
335
+ return row
336
+
337
+ def get(self, principal: Principal, exception_id: str) -> PolicyExceptionView:
338
+ row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_READ)
339
+ return self._view(row, principal)
340
+
341
+ # -- requests --------------------------------------------------------- #
342
+ def request(
343
+ self,
344
+ principal: Principal,
345
+ account_id: int,
346
+ *,
347
+ rule_id: object,
348
+ scope_type: object,
349
+ scope_id: object,
350
+ action: object,
351
+ reason: object,
352
+ expires_at: object,
353
+ permanent: object = False,
354
+ ) -> PolicyExceptionView:
355
+ require(principal, Permission.EXCEPTIONS_CREATE, account_id)
356
+ now = self._now()
357
+ if not isinstance(rule_id, str) or rule_id not in DEFAULT_POLICIES:
358
+ raise InputValidationError("rule_id must be a known rule", field="rule_id")
359
+ if not isinstance(scope_type, str) or scope_type not in {s.value for s in ExceptionScope}:
360
+ raise InputValidationError(
361
+ "scope_type must be organization, group or repository", field="scope_type"
362
+ )
363
+ if action not in ("warn", "allow"):
364
+ raise InputValidationError(
365
+ "action must be warn or allow (an exception lowers enforcement)", field="action"
366
+ )
367
+ reason_text = text(reason, "reason", limit=MAX_REASON_CHARS, required=True)
368
+ if not isinstance(permanent, bool):
369
+ raise InputValidationError("permanent must be true or false", field="permanent")
370
+ settings = load_settings(self._store, account_id).settings
371
+ scope = ExceptionScope(scope_type)
372
+ clean_scope_id = self._scope_id(principal, account_id, scope, scope_id)
373
+
374
+ expires: datetime | None = None
375
+ if permanent:
376
+ if not settings.allow_permanent_exceptions:
377
+ raise PermissionDeniedError(
378
+ "This organization does not allow permanent exceptions."
379
+ )
380
+ if not principal.can(Permission.EXCEPTIONS_APPROVE, account_id):
381
+ raise PermissionDeniedError(
382
+ "Only administrators who can approve exceptions may request a permanent one."
383
+ )
384
+ if expires_at is not None:
385
+ raise InputValidationError(
386
+ "A permanent exception has no expiry.", field="expires_at"
387
+ )
388
+ else:
389
+ if not isinstance(expires_at, str):
390
+ raise InputValidationError(
391
+ "expires_at is required: exceptions are temporary", field="expires_at"
392
+ )
393
+ expires = parse_timestamp(expires_at, "expires_at")
394
+ if expires is None or expires <= now + timedelta(minutes=5):
395
+ raise InputValidationError("expires_at must be in the future", field="expires_at")
396
+ if expires > now + timedelta(days=settings.exception_max_days):
397
+ raise InputValidationError(
398
+ f"An exception may last at most {settings.exception_max_days} days.",
399
+ field="expires_at",
400
+ )
401
+ severity = rule_severity(rule_id)
402
+ requires_approval = (
403
+ severity.rank >= settings.exception_approval_min_severity.rank
404
+ or scope is not ExceptionScope.REPOSITORY
405
+ or permanent
406
+ )
407
+ status = "requested" if requires_approval else "active"
408
+ exception_id = new_id()
409
+ actor = Actor.user(principal.user_id, principal.login)
410
+ stored: list[AuditEvent] = []
411
+ with self._store.transaction() as db:
412
+ if db.execute(
413
+ "SELECT 1 FROM policy_exceptions WHERE account_id = ? AND rule_id = ? "
414
+ "AND scope_type = ? AND scope_id = ? AND status IN ('requested', 'active')",
415
+ (account_id, rule_id, scope.value, clean_scope_id),
416
+ ).fetchone():
417
+ raise ConflictError(
418
+ "An open exception for this rule and scope already exists. Revoke or cancel "
419
+ "it before requesting another."
420
+ )
421
+ db.execute(
422
+ "INSERT INTO policy_exceptions (exception_id, account_id, rule_id, scope_type, "
423
+ "scope_id, action, severity, reason, status, requires_approval, permanent, "
424
+ "expires_at, requested_at, requested_by_id, requested_by_login, activated_at, "
425
+ "updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
426
+ (
427
+ exception_id,
428
+ account_id,
429
+ rule_id,
430
+ scope.value,
431
+ clean_scope_id,
432
+ action,
433
+ severity.value,
434
+ reason_text,
435
+ status,
436
+ 1 if requires_approval else 0,
437
+ 1 if permanent else 0,
438
+ ts(expires) if expires else None,
439
+ ts(now),
440
+ principal.user_id,
441
+ principal.login,
442
+ None if requires_approval else ts(now),
443
+ ts(now),
444
+ ),
445
+ )
446
+ label = scope_label(db, account_id, scope.value, clean_scope_id) or clean_scope_id
447
+ repository = (
448
+ account_repositories(db, account_id).get(int(clean_scope_id))
449
+ if scope is ExceptionScope.REPOSITORY
450
+ else None
451
+ )
452
+ stored.append(
453
+ self._store.insert_audit_event(
454
+ db,
455
+ self._audit.build(
456
+ AuditEventType.EXCEPTION_REQUESTED,
457
+ actor=actor,
458
+ account_id=account_id,
459
+ # Repository events carry the installation too: audit visibility is
460
+ # scoped by (installation, repository) per session.
461
+ installation_id=repository.installation_id if repository else None,
462
+ repository_id=repository.repository_id if repository else None,
463
+ repository=repository.full_name if repository else None,
464
+ exception=exception_id,
465
+ rule=rule_id,
466
+ scope=scope.value,
467
+ target=label,
468
+ exception_action=action,
469
+ severity=severity.value,
470
+ expires_at=expires.isoformat() if expires else "permanent",
471
+ requires_approval=requires_approval,
472
+ reason=reason_text,
473
+ ),
474
+ )
475
+ )
476
+ if requires_approval:
477
+ emit(
478
+ db,
479
+ self._notification(
480
+ NotificationType.EXCEPTION_REQUESTED,
481
+ account_id,
482
+ exception_id,
483
+ severity=severity,
484
+ title=f"Exception requested: {rule_id} ({label})",
485
+ body=(
486
+ f"{principal.login} requested an exception lowering {rule_id} to "
487
+ f"{action} for {label} until "
488
+ f"{expires.date().isoformat() if expires else 'revoked (permanent)'}. "
489
+ f"Reason: {reason_text}. It needs approval by someone else."
490
+ ),
491
+ key_parts=(exception_id,),
492
+ ),
493
+ now,
494
+ )
495
+ else:
496
+ invalidate_scope(db, account_id, scope.value, clean_scope_id, now)
497
+ for event in stored:
498
+ self._audit.log_stored(event)
499
+ return self._view(self._row(exception_id), principal)
500
+
501
+ def _scope_id(
502
+ self, principal: Principal, account_id: int, scope: ExceptionScope, raw: object
503
+ ) -> str:
504
+ if scope is ExceptionScope.ORGANIZATION:
505
+ if raw not in (None, ""):
506
+ raise InputValidationError(
507
+ "an organization exception has no scope_id", field="scope_id"
508
+ )
509
+ return ""
510
+ if scope is ExceptionScope.GROUP:
511
+ if not is_hex_id(raw):
512
+ raise InputValidationError("scope_id must be a group ID", field="scope_id")
513
+ assert isinstance(raw, str) # noqa: S101 - checked above
514
+ rows = self._store.query(
515
+ "SELECT 1 FROM repository_groups WHERE group_id = ? AND account_id = ? "
516
+ "AND archived_at IS NULL",
517
+ (raw, account_id),
518
+ )
519
+ if not rows:
520
+ raise NotFoundError("The group was not found in this organization.")
521
+ return raw
522
+ if not isinstance(raw, int) or isinstance(raw, bool):
523
+ if isinstance(raw, str) and raw.isdigit() and len(raw) < 17:
524
+ raw = int(raw)
525
+ else:
526
+ raise InputValidationError("scope_id must be a repository ID", field="scope_id")
527
+ repository_id = int(raw)
528
+ visible = visible_repository_ids(self._store, principal, account_id)
529
+ if repository_id not in visible or repository_id not in account_repositories(
530
+ self._store, account_id
531
+ ):
532
+ raise NotFoundError("The repository was not found in this organization.")
533
+ return str(repository_id)
534
+
535
+ @staticmethod
536
+ def _notification(
537
+ notification_type: NotificationType,
538
+ account_id: int,
539
+ exception_id: str,
540
+ *,
541
+ severity: Severity,
542
+ title: str,
543
+ body: str,
544
+ key_parts: tuple[str | int, ...],
545
+ ) -> NotificationEvent:
546
+ return NotificationEvent(
547
+ type=notification_type,
548
+ account_id=account_id,
549
+ severity=severity,
550
+ resource_type="exception",
551
+ resource_id=exception_id,
552
+ dedup_key=domain_key(notification_type, account_id, *key_parts),
553
+ title=title,
554
+ body=body,
555
+ metadata={"exception": exception_id},
556
+ )
557
+
558
+ # -- decisions -------------------------------------------------------- #
559
+ def approve(self, principal: Principal, exception_id: str, note: object) -> PolicyExceptionView:
560
+ row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_APPROVE)
561
+ account_id = int(row["account_id"])
562
+ if row["status"] != "requested":
563
+ raise ConflictError(f"The exception is {row['status']}, not waiting for approval.")
564
+ if row["requested_by_id"] == principal.user_id:
565
+ raise PermissionDeniedError("An exception must be approved by someone else.")
566
+ note_text = text(note, "note", limit=MAX_REASON_CHARS)
567
+ now = self._now()
568
+ expires = dt(row["expires_at"])
569
+ if not row["permanent"] and (expires is None or expires <= now):
570
+ raise ConflictError("The exception's expiry has passed; request a new one.")
571
+ actor = Actor.user(principal.user_id, principal.login)
572
+ with self._store.transaction() as db:
573
+ changed = db.execute(
574
+ "UPDATE policy_exceptions SET status = 'active', decided_at = ?, "
575
+ "decided_by_id = ?, "
576
+ "decided_by_login = ?, decision_note = ?, activated_at = ?, updated_at = ? "
577
+ "WHERE exception_id = ? AND status = 'requested'",
578
+ (
579
+ ts(now),
580
+ principal.user_id,
581
+ principal.login,
582
+ note_text,
583
+ ts(now),
584
+ ts(now),
585
+ exception_id,
586
+ ),
587
+ ).rowcount
588
+ if changed != 1:
589
+ raise ConflictError("The exception was changed by someone else.")
590
+ label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
591
+ invalidate_scope(db, account_id, row["scope_type"], row["scope_id"], now)
592
+ stored = self._store.insert_audit_event(
593
+ db,
594
+ self._audit.build(
595
+ AuditEventType.EXCEPTION_APPROVED,
596
+ actor=actor,
597
+ account_id=account_id,
598
+ exception=exception_id,
599
+ rule=row["rule_id"],
600
+ scope=row["scope_type"],
601
+ target=label,
602
+ requested_by=row["requested_by_login"],
603
+ expires_at=expires.isoformat() if expires else "permanent",
604
+ note=note_text,
605
+ ),
606
+ )
607
+ emit(
608
+ db,
609
+ self._notification(
610
+ NotificationType.EXCEPTION_APPROVED,
611
+ account_id,
612
+ exception_id,
613
+ severity=Severity(row["severity"]),
614
+ title=f"Exception approved: {row['rule_id']} ({label})",
615
+ body=(
616
+ f"{principal.login} approved {row['requested_by_login']}'s exception: "
617
+ f"{row['rule_id']} is {row['action']} for {label} until "
618
+ f"{expires.date().isoformat() if expires else 'revoked (permanent)'}. "
619
+ f"Reason: {row['reason']}"
620
+ ),
621
+ key_parts=(exception_id,),
622
+ ),
623
+ now,
624
+ )
625
+ self._audit.log_stored(stored)
626
+ return self._view(self._row(exception_id), principal)
627
+
628
+ def reject(self, principal: Principal, exception_id: str, note: object) -> PolicyExceptionView:
629
+ row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_APPROVE)
630
+ if row["status"] != "requested":
631
+ raise ConflictError(f"The exception is {row['status']}, not waiting for approval.")
632
+ note_text = text(note, "note", limit=MAX_REASON_CHARS, required=True)
633
+ return self._end(
634
+ principal,
635
+ row,
636
+ status="rejected",
637
+ from_status="requested",
638
+ audit_type=AuditEventType.EXCEPTION_REJECTED,
639
+ note=note_text,
640
+ )
641
+
642
+ def cancel(self, principal: Principal, exception_id: str) -> PolicyExceptionView:
643
+ row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_READ)
644
+ account_id = int(row["account_id"])
645
+ if row["status"] != "requested":
646
+ raise ConflictError(
647
+ f"The exception is {row['status']}; only requests can be cancelled."
648
+ )
649
+ if row["requested_by_id"] != principal.user_id and not principal.can(
650
+ Permission.EXCEPTIONS_REVOKE, account_id
651
+ ):
652
+ raise PermissionDeniedError()
653
+ return self._end(
654
+ principal,
655
+ row,
656
+ status="cancelled",
657
+ from_status="requested",
658
+ audit_type=AuditEventType.EXCEPTION_CANCELLED,
659
+ note=None,
660
+ )
661
+
662
+ def revoke(
663
+ self, principal: Principal, exception_id: str, reason: object
664
+ ) -> PolicyExceptionView:
665
+ row = self._authorized_row(principal, exception_id, Permission.EXCEPTIONS_REVOKE)
666
+ if row["status"] != "active":
667
+ raise ConflictError(f"The exception is {row['status']}; only active ones are revoked.")
668
+ reason_text = text(reason, "reason", limit=MAX_REASON_CHARS, required=True)
669
+ return self._end(
670
+ principal,
671
+ row,
672
+ status="revoked",
673
+ from_status="active",
674
+ audit_type=AuditEventType.EXCEPTION_REVOKED,
675
+ note=reason_text,
676
+ )
677
+
678
+ def _end(
679
+ self,
680
+ principal: Principal,
681
+ row: sqlite3.Row,
682
+ *,
683
+ status: str,
684
+ from_status: str,
685
+ audit_type: AuditEventType,
686
+ note: str | None,
687
+ ) -> PolicyExceptionView:
688
+ account_id = int(row["account_id"])
689
+ exception_id = str(row["exception_id"])
690
+ now = self._now()
691
+ actor = Actor.user(principal.user_id, principal.login)
692
+ with self._store.transaction() as db:
693
+ if status == "revoked":
694
+ sql = (
695
+ "UPDATE policy_exceptions SET status = 'revoked', revoked_at = ?, "
696
+ "revoked_by_login = ?, revoke_reason = ?, updated_at = ? "
697
+ "WHERE exception_id = ? AND status = ?"
698
+ )
699
+ else:
700
+ sql = (
701
+ "UPDATE policy_exceptions SET status = ?, decided_at = ?, "
702
+ "decided_by_login = ?, "
703
+ "decision_note = ?, updated_at = ? WHERE exception_id = ? AND status = ?"
704
+ )
705
+ params: tuple[object, ...] = (
706
+ (ts(now), principal.login, note, ts(now), exception_id, from_status)
707
+ if status == "revoked"
708
+ else (status, ts(now), principal.login, note, ts(now), exception_id, from_status)
709
+ )
710
+ if db.execute(sql, params).rowcount != 1:
711
+ raise ConflictError("The exception was changed by someone else.")
712
+ label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
713
+ if from_status == "active":
714
+ invalidate_scope(db, account_id, row["scope_type"], row["scope_id"], now)
715
+ stored = self._store.insert_audit_event(
716
+ db,
717
+ self._audit.build(
718
+ audit_type,
719
+ actor=actor,
720
+ account_id=account_id,
721
+ exception=exception_id,
722
+ rule=row["rule_id"],
723
+ scope=row["scope_type"],
724
+ target=label,
725
+ note=note,
726
+ ),
727
+ )
728
+ if status == "revoked":
729
+ emit(
730
+ db,
731
+ self._notification(
732
+ NotificationType.EXCEPTION_ENDED,
733
+ account_id,
734
+ exception_id,
735
+ severity=Severity(row["severity"]),
736
+ title=f"Exception revoked: {row['rule_id']} ({label})",
737
+ body=(
738
+ f"{principal.login} revoked the exception for {row['rule_id']} on "
739
+ f"{label}. The policy applies again. Reason: {note}"
740
+ ),
741
+ key_parts=(exception_id, "ended"),
742
+ ),
743
+ now,
744
+ )
745
+ self._audit.log_stored(stored)
746
+ return self._view(self._row(exception_id), principal)
747
+
748
+ # -- background ------------------------------------------------------- #
749
+ def expire_due(self) -> int:
750
+ """ACTIVE -> EXPIRED for exceptions past their expiry; the policy applies again."""
751
+ now = self._now()
752
+ rows = self._store.query(
753
+ "SELECT * FROM policy_exceptions WHERE status = 'active' AND permanent = 0 "
754
+ "AND expires_at <= ? ORDER BY expires_at LIMIT ?",
755
+ (ts(now), EXPIRY_BATCH),
756
+ )
757
+ expired = 0
758
+ for row in rows:
759
+ account_id = int(row["account_id"])
760
+ with self._store.transaction() as db:
761
+ if (
762
+ db.execute(
763
+ "UPDATE policy_exceptions SET status = 'expired', expired_at = ?, "
764
+ "updated_at = ? WHERE exception_id = ? AND status = 'active'",
765
+ (ts(now), ts(now), row["exception_id"]),
766
+ ).rowcount
767
+ != 1
768
+ ):
769
+ continue
770
+ label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
771
+ invalidate_scope(db, account_id, row["scope_type"], row["scope_id"], now)
772
+ stored = self._store.insert_audit_event(
773
+ db,
774
+ self._audit.build(
775
+ AuditEventType.EXCEPTION_EXPIRED,
776
+ actor=SYSTEM_ACTOR,
777
+ account_id=account_id,
778
+ exception=row["exception_id"],
779
+ rule=row["rule_id"],
780
+ scope=row["scope_type"],
781
+ target=label,
782
+ expires_at=req_dt(row["expires_at"]).isoformat(),
783
+ ),
784
+ )
785
+ emit(
786
+ db,
787
+ self._notification(
788
+ NotificationType.EXCEPTION_ENDED,
789
+ account_id,
790
+ row["exception_id"],
791
+ severity=Severity(row["severity"]),
792
+ title=f"Exception expired: {row['rule_id']} ({label})",
793
+ body=(
794
+ f"The exception lowering {row['rule_id']} to {row['action']} for "
795
+ f"{label} expired. The policy applies again to new scans."
796
+ ),
797
+ key_parts=(row["exception_id"], "ended"),
798
+ ),
799
+ now,
800
+ )
801
+ self._audit.log_stored(stored)
802
+ expired += 1
803
+ return expired
804
+
805
+ def warn_expiring(self) -> int:
806
+ """One warning per configured threshold crossed; never repeated per polling cycle."""
807
+ now = self._now()
808
+ horizon = now + timedelta(days=90)
809
+ rows = self._store.query(
810
+ "SELECT * FROM policy_exceptions WHERE status = 'active' AND permanent = 0 "
811
+ "AND expires_at > ? AND expires_at <= ? ORDER BY expires_at LIMIT ?",
812
+ (ts(now), ts(horizon), EXPIRY_BATCH),
813
+ )
814
+ warned = 0
815
+ for row in rows:
816
+ account_id = int(row["account_id"])
817
+ thresholds = load_settings(self._store, account_id).settings.exception_warning_days
818
+ expires = req_dt(row["expires_at"])
819
+ sent = {int(d) for d in json_list(row["warnings_sent"]) if isinstance(d, int)}
820
+ crossed = [d for d in thresholds if expires - now <= timedelta(days=d)]
821
+ pending = [d for d in crossed if d not in sent]
822
+ if not pending:
823
+ continue
824
+ day = min(pending) # the most urgent threshold crossed; larger ones are implied
825
+ with self._store.transaction() as db:
826
+ db.execute(
827
+ "UPDATE policy_exceptions SET warnings_sent = ?, updated_at = ? "
828
+ "WHERE exception_id = ? AND status = 'active'",
829
+ (json.dumps(sorted(sent | set(crossed))), ts(now), row["exception_id"]),
830
+ )
831
+ label = scope_label(db, account_id, row["scope_type"], row["scope_id"]) or ""
832
+ emit(
833
+ db,
834
+ self._notification(
835
+ NotificationType.EXCEPTION_EXPIRING,
836
+ account_id,
837
+ row["exception_id"],
838
+ severity=Severity(row["severity"]),
839
+ title=f"Exception expires soon: {row['rule_id']} ({label})",
840
+ body=(
841
+ f"The exception lowering {row['rule_id']} to {row['action']} for "
842
+ f"{label} expires on {expires.isoformat(timespec='minutes')} "
843
+ f"(within {day} day(s)). Afterwards the policy applies again."
844
+ ),
845
+ key_parts=(row["exception_id"], "expiring", day),
846
+ ),
847
+ now,
848
+ )
849
+ warned += 1
850
+ return warned
851
+
852
+ def counts(
853
+ self, account_id: int, repository_id: int, group_ids: Sequence[str]
854
+ ) -> tuple[int, int]:
855
+ """(active exceptions, expiring within 7 days) that apply to a repository."""
856
+ now = self._now()
857
+ grants = active_grants(self._store, account_id, repository_id, group_ids, now)
858
+ soon = sum(
859
+ 1 for g in grants if g.expires_at is not None and g.expires_at - now <= EXPIRING_SOON
860
+ )
861
+ return len(grants), soon