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,1814 @@
1
+ """Read-side services for the dashboard API.
2
+
3
+ Tenant isolation
4
+ ================
5
+
6
+ Every public method takes an :class:`~commitguard.controlplane.access.AccessScope`
7
+ and every statement filters on it:
8
+
9
+ * ``installation_id IN (SELECT value FROM json_each(?))`` - installations of
10
+ accounts where the user holds the required permission and that GitHub
11
+ reported as accessible at sign-in;
12
+ * ``EXISTS (SELECT 1 FROM session_repositories ...)`` - the repository was in
13
+ the user's GitHub-reported repository list for this session.
14
+
15
+ A row outside the scope is indistinguishable from a row that does not exist:
16
+ detail lookups return ``None`` (the API answers 404) rather than "forbidden".
17
+
18
+ SQL safety: statements are assembled only from the constant fragments in this
19
+ module (filters present or absent, one of a fixed set of ``ORDER BY`` clauses);
20
+ every value, including search text and cursor positions, is a bound
21
+ parameter. Search uses ``LIKE ... ESCAPE '\\'`` on escaped input.
22
+ """
23
+
24
+ import json
25
+ from collections.abc import Callable, Mapping, Sequence
26
+ from dataclasses import dataclass
27
+ from datetime import UTC, datetime, timedelta
28
+ from sqlite3 import Row
29
+ from typing import Any, Literal
30
+
31
+ from commitguard.audit.models import AuditEvent, AuditEventType
32
+ from commitguard.controlplane.access import AccessScope, Permission, Principal
33
+ from commitguard.controlplane.pagination import (
34
+ Page,
35
+ decode_cursor,
36
+ encode_cursor,
37
+ like_pattern,
38
+ sha_prefix,
39
+ )
40
+ from commitguard.controlplane.results import scan_result_label
41
+ from commitguard.controlplane.rules import remediation_steps
42
+ from commitguard.controlplane.views import (
43
+ AcknowledgementView,
44
+ ActorView,
45
+ AppConnection,
46
+ AuditEventView,
47
+ DetectionView,
48
+ EnforcementSignal,
49
+ EnforcementView,
50
+ EvidenceView,
51
+ ExecutionHistory,
52
+ ExecutionView,
53
+ ExposureView,
54
+ FindingView,
55
+ HealthCheck,
56
+ HealthCheckStatus,
57
+ InstallationDetail,
58
+ InstallationRepositoryView,
59
+ InstallationView,
60
+ IntegrationStatus,
61
+ IntegrationView,
62
+ LatestCheckSignal,
63
+ MatchView,
64
+ MergeGroupView,
65
+ MergeQueueStatus,
66
+ MergeQueueView,
67
+ OrganizationRef,
68
+ OverviewPeriod,
69
+ OverviewSummary,
70
+ OverviewView,
71
+ PolicyEntry,
72
+ ProtectionStatus,
73
+ RepositoryDetail,
74
+ RepositoryLink,
75
+ RepositoryPermissions,
76
+ RepositorySummary,
77
+ RequiredCheckSignal,
78
+ RequiredCheckStatus,
79
+ ScanComparison,
80
+ ScanDetail,
81
+ ScanFailure,
82
+ ScanResultStatus,
83
+ ScanSummary,
84
+ ViolationDetail,
85
+ ViolationStatus,
86
+ ViolationSummary,
87
+ )
88
+ from commitguard.core.decision import Action
89
+ from commitguard.core.result import Severity
90
+ from commitguard.github.permissions import (
91
+ REQUIRED_PERMISSIONS,
92
+ excessive_permissions,
93
+ level_rank,
94
+ missing_permissions,
95
+ )
96
+ from commitguard.github.storage import MergeGroupRecord, SqliteStateStore
97
+
98
+ SCOPE_JOBS = (
99
+ "j.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 FROM "
100
+ "session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = j.installation_id "
101
+ "AND sr.repository_id = j.repository_id)"
102
+ )
103
+ SCOPE_VIOLATIONS = (
104
+ "v.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 FROM "
105
+ "session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = v.installation_id "
106
+ "AND sr.repository_id = v.repository_id)"
107
+ )
108
+ SCOPE_REPOSITORIES = (
109
+ "r.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 FROM "
110
+ "session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = r.installation_id "
111
+ "AND sr.repository_id = r.repository_id)"
112
+ )
113
+ SCOPE_AUDIT = (
114
+ "a.account_id IN (SELECT value FROM json_each(?)) AND (a.installation_id IS NULL OR "
115
+ "a.installation_id IN (SELECT value FROM json_each(?))) AND (a.repository_id IS NULL OR "
116
+ "EXISTS (SELECT 1 FROM session_repositories sr WHERE sr.session_hash = ? AND "
117
+ "sr.installation_id = a.installation_id AND sr.repository_id = a.repository_id))"
118
+ )
119
+ SCOPE_INSTALLATIONS = "i.installation_id IN (SELECT value FROM json_each(?))"
120
+
121
+ _SCAN_COLUMNS = (
122
+ "j.job_id, j.scan_id, j.installation_id, j.repository_id, j.owner, j.name, j.event, "
123
+ "j.pull_request_number, j.ref, j.base_sha, j.head_sha, j.check_name, j.state, "
124
+ "j.result_action, j.commits_scanned, j.violations, j.warnings, j.findings_count, "
125
+ "j.created_at, j.started_at, j.completed_at, j.requested_by, j.sequence, j.group_key, "
126
+ "j.conclusion, j.failure_kind, j.message, j.tool_version, j.rules_version, j.policy_version, "
127
+ "j.policy_source, j.organization_policy_version, j.effective_policies, "
128
+ "j.detector_failures, j.notices, j.scan_key, j.execution, j.trigger_kind, "
129
+ "(SELECT account_id FROM installations i WHERE "
130
+ "i.installation_id = j.installation_id) AS account_id"
131
+ )
132
+ _VIOLATION_COLUMNS = (
133
+ "v.violation_id, v.installation_id, v.repository_id, v.rule_id, v.detector, v.title, "
134
+ "v.severity, v.severity_rank, v.action, v.commit_sha, v.author, v.status, "
135
+ "v.first_detected_at, v.last_detected_at, v.detections, v.acknowledged_at, "
136
+ "v.acknowledged_by_login, v.acknowledgement_note, v.resolved_at, v.resolution, "
137
+ "v.first_job_id, v.last_job_id, "
138
+ "(SELECT owner || '/' || name FROM known_repositories k WHERE "
139
+ "k.installation_id = v.installation_id AND k.repository_id = v.repository_id) AS full_name, "
140
+ "(SELECT account_id FROM installations i WHERE i.installation_id = v.installation_id) "
141
+ "AS account_id"
142
+ )
143
+
144
+ SCAN_RESULTS: Mapping[str, ScanResultStatus] = {s.value: s for s in ScanResultStatus}
145
+ _RESULT_CLAUSES: Mapping[ScanResultStatus, str] = {
146
+ ScanResultStatus.QUEUED: "j.state = 'queued'",
147
+ ScanResultStatus.RUNNING: "j.state = 'running'",
148
+ ScanResultStatus.PASS: "j.state = 'passed' AND COALESCE(j.result_action, '') != 'warn'",
149
+ ScanResultStatus.WARNING: "j.state = 'passed' AND j.result_action = 'warn'",
150
+ ScanResultStatus.BLOCKED: "j.state = 'failed'",
151
+ ScanResultStatus.ERROR: "j.state = 'error'",
152
+ ScanResultStatus.CANCELLED: "j.state = 'cancelled' AND COALESCE(j.failure_kind, '') != 'stale'",
153
+ ScanResultStatus.STALE: "j.state = 'cancelled' AND j.failure_kind = 'stale'",
154
+ }
155
+ SCAN_SORTS = ("newest", "oldest")
156
+ VIOLATION_SORTS = ("newest", "oldest", "severity", "repository")
157
+ REPOSITORY_SORTS = ("name", "risk", "recent")
158
+ AUDIT_SORTS = ("newest", "oldest")
159
+ PERIODS: Mapping[str, timedelta] = {
160
+ "24h": timedelta(hours=24),
161
+ "7d": timedelta(days=7),
162
+ "30d": timedelta(days=30),
163
+ }
164
+
165
+
166
+ def _dt(value: float | None) -> datetime | None:
167
+ return None if value is None else datetime.fromtimestamp(value, UTC)
168
+
169
+
170
+ def _req_dt(value: float) -> datetime:
171
+ return datetime.fromtimestamp(value, UTC)
172
+
173
+
174
+ class _Where:
175
+ """Conditions built from constant fragments; values are always parameters."""
176
+
177
+ def __init__(self, scope_clause: str, *scope_params: Any) -> None:
178
+ self.clauses = [scope_clause]
179
+ self.params: list[Any] = list(scope_params)
180
+
181
+ def add(self, clause: str, *params: Any) -> None:
182
+ self.clauses.append(clause)
183
+ self.params.extend(params)
184
+
185
+ @property
186
+ def sql(self) -> str:
187
+ return " AND ".join(f"({c})" for c in self.clauses)
188
+
189
+
190
+ @dataclass(frozen=True, slots=True)
191
+ class ScanFilters:
192
+ organization_id: int | None = None
193
+ repository_id: int | None = None
194
+ result: ScanResultStatus | None = None
195
+ event: Literal["pull_request", "push"] | None = None
196
+ rule_id: str | None = None
197
+ severity: Severity | None = None
198
+ start: datetime | None = None
199
+ end: datetime | None = None
200
+ q: str | None = None
201
+ sort: str = "newest"
202
+
203
+
204
+ @dataclass(frozen=True, slots=True)
205
+ class ViolationFilters:
206
+ organization_id: int | None = None
207
+ repository_id: int | None = None
208
+ status: ViolationStatus | None = None
209
+ severity: Severity | None = None
210
+ rule_id: str | None = None
211
+ action: Action | None = None
212
+ start: datetime | None = None
213
+ end: datetime | None = None
214
+ q: str | None = None
215
+ sort: str = "newest"
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class RepositoryFilters:
220
+ organization_id: int | None = None
221
+ protection: ProtectionStatus | None = None
222
+ q: str | None = None
223
+ sort: str = "name"
224
+
225
+
226
+ @dataclass(frozen=True, slots=True)
227
+ class AuditFilters:
228
+ organization_id: int | None = None
229
+ repository_id: int | None = None
230
+ event_type: AuditEventType | None = None
231
+ actor: str | None = None
232
+ start: datetime | None = None
233
+ end: datetime | None = None
234
+ sort: str = "newest"
235
+ rule_id: str | None = None # events about one rule (exceptions, violations)
236
+ exception_id: str | None = None
237
+ #: "organization" (organization policy events) or a draft, group or rollout ID.
238
+ policy: str | None = None
239
+
240
+
241
+ # --------------------------------------------------------------------------- #
242
+ # Row conversion
243
+ # --------------------------------------------------------------------------- #
244
+ def scan_summary(row: Row) -> ScanSummary:
245
+ started = _dt(row["started_at"])
246
+ completed = _dt(row["completed_at"])
247
+ duration = (
248
+ int((completed - started).total_seconds() * 1000)
249
+ if started is not None and completed is not None and completed >= started
250
+ else None
251
+ )
252
+ return ScanSummary(
253
+ id=row["job_id"],
254
+ scan_id=row["scan_id"],
255
+ repository=RepositoryLink(
256
+ id=row["repository_id"],
257
+ installation_id=row["installation_id"],
258
+ full_name=f"{row['owner']}/{row['name']}",
259
+ ),
260
+ organization_id=row["account_id"],
261
+ event=row["event"],
262
+ pull_request_number=row["pull_request_number"],
263
+ ref=row["ref"],
264
+ base_sha=row["base_sha"],
265
+ head_sha=row["head_sha"],
266
+ check_name=row["check_name"],
267
+ result=ScanResultStatus(
268
+ scan_result_label(row["state"], row["result_action"], row["failure_kind"])
269
+ ),
270
+ commits_scanned=row["commits_scanned"],
271
+ violations=row["violations"],
272
+ warnings=row["warnings"],
273
+ findings=row["findings_count"],
274
+ created_at=_req_dt(row["created_at"]),
275
+ started_at=started,
276
+ completed_at=completed,
277
+ duration_ms=duration,
278
+ requested_by=row["requested_by"],
279
+ trigger=row["trigger_kind"] or row["event"],
280
+ execution=row["execution"] or 1,
281
+ failure_source=_failure_source(row["event"]),
282
+ )
283
+
284
+
285
+ def _failure_source(event: str) -> Literal["pull_request", "push", "merge_queue"]:
286
+ if event == "merge_group":
287
+ return "merge_queue"
288
+ return "push" if event in ("push", "scheduled") else "pull_request"
289
+
290
+
291
+ def _duration_ms(started: datetime | None, completed: datetime | None) -> int | None:
292
+ if started is None or completed is None or completed < started:
293
+ return None
294
+ return int((completed - started).total_seconds() * 1000)
295
+
296
+
297
+ def merge_group_view(record: MergeGroupRecord, job: Row | None) -> MergeGroupView:
298
+ return MergeGroupView(
299
+ head_sha=record.head_sha,
300
+ base_sha=record.base_sha,
301
+ base_ref=record.base_ref,
302
+ pull_requests=record.pull_requests,
303
+ state=record.state.value,
304
+ destroyed_reason=record.destroyed_reason,
305
+ result=ScanResultStatus(
306
+ scan_result_label(job["state"], job["result_action"], job["failure_kind"])
307
+ )
308
+ if job is not None
309
+ else None,
310
+ scan=job["job_id"] if job is not None else None,
311
+ created_at=record.created_at,
312
+ updated_at=record.updated_at,
313
+ validated_at=_dt(job["completed_at"]) if job is not None else None,
314
+ )
315
+
316
+
317
+ def _evidence(document: str) -> tuple[EvidenceView, ...]:
318
+ items = json.loads(document)
319
+ return tuple(
320
+ EvidenceView(
321
+ source=item["source"],
322
+ source_label=item["source_label"],
323
+ value=item["value"],
324
+ line_number=item.get("line_number"),
325
+ matched=tuple(MatchView(**m) for m in item.get("matched", [])),
326
+ notes=tuple(item.get("notes", [])),
327
+ )
328
+ for item in items
329
+ )
330
+
331
+
332
+ def finding_view(row: Row) -> FindingView:
333
+ return FindingView(
334
+ id=row["finding_id"],
335
+ violation_id=row["violation_id"],
336
+ rule_id=row["rule_id"],
337
+ detector=row["detector"],
338
+ title=row["title"],
339
+ message=row["message"],
340
+ severity=Severity(row["severity"]),
341
+ confidence=row["confidence"],
342
+ action=Action(row["action"]),
343
+ reason=row["reason"],
344
+ commit_sha=row["commit_sha"],
345
+ author=row["author"],
346
+ committer=row["committer"],
347
+ evidence=_evidence(row["evidence"]),
348
+ remediation=row["remediation"],
349
+ )
350
+
351
+
352
+ def _violation_status(row: Row) -> ViolationStatus:
353
+ if row["status"] == "resolved":
354
+ return ViolationStatus.RESOLVED
355
+ if row["acknowledged_at"] is not None:
356
+ return ViolationStatus.ACKNOWLEDGED
357
+ return ViolationStatus.OPEN
358
+
359
+
360
+ def violation_summary(row: Row) -> ViolationSummary:
361
+ return ViolationSummary(
362
+ id=row["violation_id"],
363
+ rule_id=row["rule_id"],
364
+ title=row["title"],
365
+ severity=Severity(row["severity"]),
366
+ action=Action(row["action"]),
367
+ repository=RepositoryLink(
368
+ id=row["repository_id"],
369
+ installation_id=row["installation_id"],
370
+ full_name=row["full_name"] or f"repository {row['repository_id']}",
371
+ ),
372
+ organization_id=row["account_id"],
373
+ commit_sha=row["commit_sha"],
374
+ author=row["author"],
375
+ status=_violation_status(row),
376
+ first_detected_at=_req_dt(row["first_detected_at"]),
377
+ last_detected_at=_req_dt(row["last_detected_at"]),
378
+ detections=row["detections"],
379
+ )
380
+
381
+
382
+ def _policies(document: str | None) -> tuple[PolicyEntry, ...]:
383
+ return tuple(PolicyEntry(**entry) for entry in json.loads(document or "[]"))
384
+
385
+
386
+ def audit_summary(event: AuditEvent) -> str:
387
+ """A one-line, server-side description of an audit event."""
388
+ data = event.data
389
+ t = AuditEventType
390
+
391
+ def value(key: str, default: str = "") -> str:
392
+ item = data.get(key)
393
+ return default if item is None else str(item)
394
+
395
+ texts: dict[AuditEventType, Callable[[], str]] = {
396
+ t.INSTALLATION_CREATED: lambda: "GitHub App installed",
397
+ t.INSTALLATION_REMOVED: lambda: "GitHub App uninstalled",
398
+ t.INSTALLATION_SUSPENDED: lambda: "GitHub App installation suspended",
399
+ t.INSTALLATION_UNSUSPENDED: lambda: "GitHub App installation unsuspended",
400
+ t.INSTALLATION_PERMISSIONS_UPDATED: lambda: "GitHub App permissions updated",
401
+ t.REPOSITORIES_ADDED: lambda: f"{value('repositories', '0')} repositories connected",
402
+ t.REPOSITORIES_REMOVED: lambda: f"{value('repositories', '0')} repositories disconnected",
403
+ t.REPOSITORIES_SYNCED: lambda: (
404
+ f"Repositories synchronized ({value('added', '0')} added, "
405
+ f"{value('removed', '0')} removed)"
406
+ ),
407
+ t.WEBHOOK_REJECTED: lambda: f"Webhook rejected: {value('reason')}",
408
+ t.SCAN_QUEUED: lambda: "Scan queued",
409
+ t.SCAN_REQUESTED: lambda: "Re-scan requested",
410
+ t.REPOSITORY_SCANNED: lambda: f"Scanned {value('commits_scanned', '0')} commit(s)",
411
+ t.SCAN_PASSED: lambda: "Scan completed: passed",
412
+ t.SCAN_FAILED: lambda: f"Scan completed: blocked ({value('violations', '0')} violation(s))",
413
+ t.POLICY_VIOLATION: lambda: f"Policy violation: {value('rules')}",
414
+ t.POLICY_MODIFICATION: lambda: f"Security policy modification detected: {value('changes')}",
415
+ t.CONFIGURATION_ERROR: lambda: f"Configuration error: {value('reason')}",
416
+ t.SCAN_ERROR: lambda: f"Scan could not be completed: {value('reason')}",
417
+ t.SCAN_CANCELLED: lambda: f"Scan cancelled: {value('reason')}",
418
+ t.AUTHORIZATION_DENIED: lambda: f"Access denied: {value('reason')}",
419
+ t.PULL_REQUEST_MERGED: lambda: f"Pull request #{value('pull_request')} merged",
420
+ t.USER_SIGNED_IN: lambda: "Signed in",
421
+ t.USER_SIGNED_OUT: lambda: "Signed out",
422
+ t.SESSION_REVOKED: lambda: "Session revoked",
423
+ t.MEMBER_ROLE_GRANTED: lambda: f"Granted {value('new_role')} to user {value('member')}",
424
+ t.MEMBER_ROLE_CHANGED: lambda: (
425
+ f"Changed role of user {value('member')}: {value('old_role')} → {value('new_role')}"
426
+ ),
427
+ t.MEMBER_REMOVED: lambda: f"Removed user {value('member')} ({value('old_role')})",
428
+ t.ORGANIZATION_POLICY_CHANGED: lambda: (
429
+ f"Changed organization policy v{value('old_version')} → v{value('new_version')}: "
430
+ f"{value('changes')}"
431
+ ),
432
+ t.VIOLATION_OPENED: lambda: f"Violation detected: {value('rule')}",
433
+ t.VIOLATION_REOPENED: lambda: f"Violation reopened: {value('rule')}",
434
+ t.VIOLATION_RESOLVED: lambda: f"Violation resolved: {value('reason')}",
435
+ t.VIOLATION_ACKNOWLEDGED: lambda: f"Violation acknowledged: {value('rule')}",
436
+ t.VIOLATION_ACKNOWLEDGEMENT_REMOVED: lambda: f"Acknowledgement removed: {value('rule')}",
437
+ t.REPOSITORY_MONITORING_DISABLED: lambda: f"Monitoring paused: {value('reason')}",
438
+ t.REPOSITORY_MONITORING_ENABLED: lambda: "Monitoring resumed",
439
+ t.ENFORCEMENT_STATUS_CHECKED: lambda: (
440
+ f"Enforcement checked: Actions {value('actions')}, "
441
+ f"required check {value('branch_protection')}"
442
+ ),
443
+ }
444
+ return texts.get(event.type, lambda: event.type.value.replace("_", " ").capitalize())()
445
+
446
+
447
+ def audit_view(event: AuditEvent, full_names: Mapping[tuple[int, int], str]) -> AuditEventView:
448
+ repository = None
449
+ if event.repository_id is not None and event.installation_id is not None:
450
+ name = full_names.get((event.installation_id, event.repository_id)) or event.repository
451
+ repository = RepositoryLink(
452
+ id=event.repository_id,
453
+ installation_id=event.installation_id,
454
+ full_name=name or f"repository {event.repository_id}",
455
+ )
456
+ scan = event.data.get("job") if isinstance(event.data.get("job"), str) else event.job_id
457
+ return AuditEventView(
458
+ id=event.event_id,
459
+ type=event.type.value,
460
+ occurred_at=event.occurred_at,
461
+ actor=ActorView(type=event.actor_type.value, id=event.actor_id, login=event.actor_login),
462
+ organization_id=event.account_id,
463
+ installation_id=event.installation_id,
464
+ repository=repository,
465
+ head_sha=event.head_sha,
466
+ action=event.action,
467
+ summary=audit_summary(event),
468
+ data=dict(event.data),
469
+ scan=scan if isinstance(scan, str) else None,
470
+ )
471
+
472
+
473
+ # --------------------------------------------------------------------------- #
474
+ # Protection
475
+ # --------------------------------------------------------------------------- #
476
+ def protection_for(
477
+ *,
478
+ app: AppConnection,
479
+ monitoring_enabled: bool,
480
+ latest_failure_kind: str | None,
481
+ branch_protection: str | None,
482
+ detail: str | None,
483
+ ) -> tuple[ProtectionStatus, str]:
484
+ """Explicit rules; a repository is never 'protected' only because the App is installed."""
485
+ if app is AppConnection.SUSPENDED:
486
+ return (
487
+ ProtectionStatus.AT_RISK,
488
+ "The GitHub App installation is suspended: CommitGuard checks no longer run.",
489
+ )
490
+ if app is AppConnection.DISCONNECTED:
491
+ return (
492
+ ProtectionStatus.AT_RISK,
493
+ "The GitHub App no longer has access to this repository: CommitGuard checks no "
494
+ "longer run.",
495
+ )
496
+ if not monitoring_enabled:
497
+ return ProtectionStatus.UNPROTECTED, "CommitGuard monitoring is paused for this repository."
498
+ if latest_failure_kind == "configuration":
499
+ return (
500
+ ProtectionStatus.CONFIGURATION_ERROR,
501
+ "The latest scan failed because the CommitGuard configuration is invalid.",
502
+ )
503
+ if branch_protection == RequiredCheckStatus.REQUIRED.value:
504
+ return ProtectionStatus.PROTECTED, detail or "A CommitGuard check is required."
505
+ if branch_protection == RequiredCheckStatus.NOT_REQUIRED.value:
506
+ return (
507
+ ProtectionStatus.UNPROTECTED,
508
+ (detail or "No CommitGuard check is required.")
509
+ + " Failing checks do not block merges.",
510
+ )
511
+ return (
512
+ ProtectionStatus.UNKNOWN,
513
+ "Scanned by the GitHub App, but branch protection has not been verified.",
514
+ )
515
+
516
+
517
+ @dataclass(frozen=True, slots=True)
518
+ class _RepoRow:
519
+ row: Row
520
+ summary: RepositorySummary
521
+
522
+
523
+ class DashboardQueries:
524
+ def __init__(
525
+ self,
526
+ store: SqliteStateStore,
527
+ *,
528
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
529
+ ) -> None:
530
+ self._store = store
531
+ self._now = now
532
+
533
+ # ------------------------------------------------------------------ #
534
+ # Scans
535
+ # ------------------------------------------------------------------ #
536
+ def list_scans(
537
+ self, scope: AccessScope, filters: ScanFilters, *, cursor: str | None, limit: int
538
+ ) -> Page[ScanSummary]:
539
+ if not scope.installation_ids:
540
+ return Page([], None, limit)
541
+ where = _Where(SCOPE_JOBS, scope.installations_json, scope.session_hash)
542
+ self._scan_filters(where, filters)
543
+ position = decode_cursor(cursor, (int,))
544
+ newest = filters.sort != "oldest"
545
+ if position is not None:
546
+ where.add("j.sequence < ?" if newest else "j.sequence > ?", position[0])
547
+ order = "j.sequence DESC" if newest else "j.sequence ASC"
548
+ rows = self._store.query(
549
+ " ".join(
550
+ (
551
+ "SELECT",
552
+ _SCAN_COLUMNS,
553
+ "FROM scan_jobs j WHERE",
554
+ where.sql,
555
+ "ORDER BY",
556
+ order,
557
+ "LIMIT ?",
558
+ )
559
+ ),
560
+ (*where.params, limit + 1),
561
+ )
562
+ items = [scan_summary(r) for r in rows[:limit]]
563
+ next_cursor = encode_cursor([rows[limit - 1]["sequence"]]) if len(rows) > limit else None
564
+ return Page(items, next_cursor, limit)
565
+
566
+ @staticmethod
567
+ def _scan_filters(where: _Where, filters: ScanFilters) -> None:
568
+ if filters.organization_id is not None:
569
+ where.add(
570
+ "j.installation_id IN (SELECT installation_id FROM installations "
571
+ "WHERE account_id = ?)",
572
+ filters.organization_id,
573
+ )
574
+ if filters.repository_id is not None:
575
+ where.add("j.repository_id = ?", filters.repository_id)
576
+ if filters.result is not None:
577
+ where.add(_RESULT_CLAUSES[filters.result])
578
+ if filters.event is not None:
579
+ where.add("j.event = ?", filters.event)
580
+ if filters.rule_id is not None:
581
+ where.add(
582
+ "EXISTS (SELECT 1 FROM findings f WHERE f.job_id = j.job_id AND f.rule_id = ?)",
583
+ filters.rule_id,
584
+ )
585
+ if filters.severity is not None:
586
+ where.add(
587
+ "EXISTS (SELECT 1 FROM findings f WHERE f.job_id = j.job_id AND f.severity = ?)",
588
+ filters.severity.value,
589
+ )
590
+ if filters.start is not None:
591
+ where.add("j.created_at >= ?", filters.start.timestamp())
592
+ if filters.end is not None:
593
+ where.add("j.created_at < ?", filters.end.timestamp())
594
+ if filters.q is not None:
595
+ prefix = sha_prefix(filters.q)
596
+ if prefix is not None:
597
+ where.add(
598
+ "(j.head_sha LIKE ? OR (j.owner || '/' || j.name) LIKE ? ESCAPE '\\')",
599
+ prefix + "%",
600
+ like_pattern(filters.q),
601
+ )
602
+ else:
603
+ where.add("(j.owner || '/' || j.name) LIKE ? ESCAPE '\\'", like_pattern(filters.q))
604
+
605
+ def _scan_row(self, scope: AccessScope, scan_id: str) -> Row | None:
606
+ if not scope.installation_ids or not _is_hex_id(scan_id):
607
+ return None
608
+ rows = self._store.query(
609
+ " ".join(
610
+ ("SELECT", _SCAN_COLUMNS, "FROM scan_jobs j WHERE j.job_id = ? AND", SCOPE_JOBS)
611
+ ),
612
+ (scan_id, scope.installations_json, scope.session_hash),
613
+ )
614
+ return rows[0] if rows else None
615
+
616
+ def get_scan(
617
+ self, scope: AccessScope, scan_id: str, *, principal: Principal
618
+ ) -> ScanDetail | None:
619
+ row = self._scan_row(scope, scan_id)
620
+ if row is None:
621
+ return None
622
+ findings = self._store.query(
623
+ "SELECT * FROM findings WHERE job_id = ? ORDER BY severity_rank DESC, finding_id",
624
+ (scan_id,),
625
+ )
626
+ summary = scan_summary(row)
627
+ can_rescan, blocked_reason = self.rescan_eligibility(row, principal)
628
+ failure = None
629
+ if row["state"] in ("error", "cancelled"):
630
+ failure = ScanFailure(kind=row["failure_kind"], message=row["message"] or "")
631
+ executions = self._store.query(
632
+ "SELECT COUNT(*) AS n, (SELECT job_id FROM scan_jobs o WHERE o.installation_id = ? "
633
+ "AND o.repository_id = ? AND o.scan_key = ? ORDER BY o.sequence DESC LIMIT 1) "
634
+ "AS latest FROM scan_jobs WHERE installation_id = ? AND repository_id = ? "
635
+ "AND scan_key = ?",
636
+ (
637
+ row["installation_id"],
638
+ row["repository_id"],
639
+ row["scan_key"],
640
+ row["installation_id"],
641
+ row["repository_id"],
642
+ row["scan_key"],
643
+ ),
644
+ )[0]
645
+ merge_group = None
646
+ if row["event"] == "merge_group":
647
+ record = self._store.get_merge_group(
648
+ row["installation_id"], row["repository_id"], row["head_sha"]
649
+ )
650
+ if record is not None:
651
+ merge_group = merge_group_view(record, row)
652
+ return ScanDetail(
653
+ executions=int(executions["n"] or 1),
654
+ latest_execution=executions["latest"] or row["job_id"],
655
+ merge_group=merge_group,
656
+ scan=summary,
657
+ conclusion=row["conclusion"],
658
+ tool_version=row["tool_version"],
659
+ rules_version=row["rules_version"],
660
+ policy_version=row["policy_version"],
661
+ policy_source=row["policy_source"],
662
+ organization_policy_version=row["organization_policy_version"],
663
+ effective_policies=_policies(row["effective_policies"]),
664
+ detector_failures=row["detector_failures"],
665
+ notices=tuple(json.loads(row["notices"] or "[]")),
666
+ failure=failure,
667
+ findings=tuple(finding_view(f) for f in findings),
668
+ can_rescan=can_rescan,
669
+ rescan_blocked_reason=blocked_reason,
670
+ )
671
+
672
+ def scan_executions(self, scope: AccessScope, scan_id: str) -> ExecutionHistory | None:
673
+ """Every execution of the logical scan ``scan_id`` belongs to, newest first."""
674
+ row = self._scan_row(scope, scan_id)
675
+ if row is None:
676
+ return None
677
+ rows = self._store.query(
678
+ " ".join(
679
+ (
680
+ "SELECT",
681
+ _SCAN_COLUMNS,
682
+ "FROM scan_jobs j WHERE j.installation_id = ? AND j.repository_id = ? "
683
+ "AND j.scan_key = ? ORDER BY j.sequence DESC LIMIT 100",
684
+ )
685
+ ),
686
+ (row["installation_id"], row["repository_id"], row["scan_key"]),
687
+ )
688
+ items = []
689
+ for index, r in enumerate(rows):
690
+ started, completed = _dt(r["started_at"]), _dt(r["completed_at"])
691
+ failure = (
692
+ ScanFailure(kind=r["failure_kind"], message=r["message"] or "")
693
+ if r["state"] in ("error", "cancelled")
694
+ else None
695
+ )
696
+ items.append(
697
+ ExecutionView(
698
+ id=r["job_id"],
699
+ execution=r["execution"] or 1,
700
+ trigger=r["trigger_kind"] or r["event"],
701
+ current=index == 0,
702
+ result=ScanResultStatus(
703
+ scan_result_label(r["state"], r["result_action"], r["failure_kind"])
704
+ ),
705
+ head_sha=r["head_sha"],
706
+ base_sha=r["base_sha"],
707
+ organization_policy_version=r["organization_policy_version"],
708
+ policy_version=r["policy_version"],
709
+ rules_version=r["rules_version"],
710
+ tool_version=r["tool_version"],
711
+ conclusion=r["conclusion"],
712
+ requested_by=r["requested_by"],
713
+ failure=failure,
714
+ created_at=_req_dt(r["created_at"]),
715
+ started_at=started,
716
+ completed_at=completed,
717
+ duration_ms=_duration_ms(started, completed),
718
+ )
719
+ )
720
+ evaluated = [i for i in items if i.policy_version is not None]
721
+ return ExecutionHistory(
722
+ scan_id=scan_id,
723
+ items=tuple(items),
724
+ policy_changed=len(
725
+ {(i.policy_version, i.organization_policy_version) for i in evaluated}
726
+ )
727
+ > 1,
728
+ rules_changed=len({i.rules_version for i in evaluated}) > 1,
729
+ )
730
+
731
+ def rescan_eligibility(self, row: Row, principal: Principal) -> tuple[bool, str | None]:
732
+ account_id = row["account_id"]
733
+ if account_id is None or not principal.can(Permission.SCANS_TRIGGER, int(account_id)):
734
+ return False, "Your role cannot request scans."
735
+ if row["state"] in ("queued", "running"):
736
+ return False, "This scan has not finished yet."
737
+ latest = self._store.latest_group_sequence(
738
+ row["installation_id"], row["repository_id"], row["group_key"]
739
+ )
740
+ if latest > row["sequence"]:
741
+ return False, "A newer scan exists for this pull request or branch."
742
+ installation = self._store.get_installation(row["installation_id"])
743
+ if installation is None or installation.state.value != "active":
744
+ return False, "The GitHub App installation is not active."
745
+ if not self._store.repository_listed(row["installation_id"], row["repository_id"]):
746
+ return False, "The GitHub App no longer has access to this repository."
747
+ if not self._store.monitoring_enabled(row["installation_id"], row["repository_id"]):
748
+ return False, "Monitoring is paused for this repository."
749
+ return True, None
750
+
751
+ def scan_row_for_command(self, scope: AccessScope, scan_id: str) -> Row | None:
752
+ return self._scan_row(scope, scan_id)
753
+
754
+ def compare_scan(self, scope: AccessScope, scan_id: str) -> ScanComparison | None:
755
+ row = self._scan_row(scope, scan_id)
756
+ if row is None:
757
+ return None
758
+ previous = self._store.query(
759
+ "SELECT job_id FROM scan_jobs WHERE installation_id = ? AND repository_id = ? "
760
+ "AND group_key = ? AND sequence < ? AND state IN ('passed', 'failed') "
761
+ "ORDER BY sequence DESC LIMIT 1",
762
+ (row["installation_id"], row["repository_id"], row["group_key"], row["sequence"]),
763
+ )
764
+ current = self._findings_by_fingerprint(scan_id)
765
+ before = self._findings_by_fingerprint(previous[0]["job_id"]) if previous else {}
766
+ new = sorted(set(current) - set(before))
767
+ resolved = sorted(set(before) - set(current))
768
+ unchanged = sorted(set(current) & set(before))
769
+ return ScanComparison(
770
+ scan_id=scan_id,
771
+ previous_scan_id=previous[0]["job_id"] if previous else None,
772
+ new=tuple(new),
773
+ resolved=tuple(resolved),
774
+ unchanged=tuple(unchanged),
775
+ new_findings=tuple(finding_view(current[f]) for f in new),
776
+ resolved_findings=tuple(finding_view(before[f]) for f in resolved),
777
+ )
778
+
779
+ def _findings_by_fingerprint(self, job_id: str) -> dict[str, Row]:
780
+ rows = self._store.query(
781
+ "SELECT * FROM findings WHERE job_id = ? ORDER BY finding_id LIMIT 5000", (job_id,)
782
+ )
783
+ return {r["fingerprint"]: r for r in rows}
784
+
785
+ # ------------------------------------------------------------------ #
786
+ # Violations
787
+ # ------------------------------------------------------------------ #
788
+ def list_violations(
789
+ self, scope: AccessScope, filters: ViolationFilters, *, cursor: str | None, limit: int
790
+ ) -> Page[ViolationSummary]:
791
+ if not scope.installation_ids:
792
+ return Page([], None, limit)
793
+ where = _Where(SCOPE_VIOLATIONS, scope.installations_json, scope.session_hash)
794
+ if filters.organization_id is not None:
795
+ where.add(
796
+ "v.installation_id IN (SELECT installation_id FROM installations "
797
+ "WHERE account_id = ?)",
798
+ filters.organization_id,
799
+ )
800
+ if filters.repository_id is not None:
801
+ where.add("v.repository_id = ?", filters.repository_id)
802
+ if filters.status is ViolationStatus.RESOLVED:
803
+ where.add("v.status = 'resolved'")
804
+ elif filters.status is ViolationStatus.ACKNOWLEDGED:
805
+ where.add("v.status = 'open' AND v.acknowledged_at IS NOT NULL")
806
+ elif filters.status is ViolationStatus.OPEN:
807
+ where.add("v.status = 'open' AND v.acknowledged_at IS NULL")
808
+ if filters.severity is not None:
809
+ where.add("v.severity = ?", filters.severity.value)
810
+ if filters.rule_id is not None:
811
+ where.add("v.rule_id = ?", filters.rule_id)
812
+ if filters.action is not None:
813
+ where.add("v.action = ?", filters.action.value)
814
+ if filters.start is not None:
815
+ where.add("v.last_detected_at >= ?", filters.start.timestamp())
816
+ if filters.end is not None:
817
+ where.add("v.last_detected_at < ?", filters.end.timestamp())
818
+ if filters.q is not None:
819
+ prefix = sha_prefix(filters.q)
820
+ pattern = like_pattern(filters.q)
821
+ where.add(
822
+ "(v.rule_id LIKE ? ESCAPE '\\' OR v.author LIKE ? ESCAPE '\\' OR "
823
+ "COALESCE(v.commit_sha, '') LIKE ? OR EXISTS (SELECT 1 FROM known_repositories k "
824
+ "WHERE k.installation_id = v.installation_id AND k.repository_id = v.repository_id "
825
+ "AND (k.owner || '/' || k.name) LIKE ? ESCAPE '\\'))",
826
+ pattern,
827
+ pattern,
828
+ (prefix + "%") if prefix else "\x00",
829
+ pattern,
830
+ )
831
+ sort = filters.sort if filters.sort in VIOLATION_SORTS else "newest"
832
+ if sort == "severity":
833
+ position = decode_cursor(cursor, (int, float, str))
834
+ if position is not None:
835
+ where.add(
836
+ "(v.severity_rank < ? OR (v.severity_rank = ? AND (v.last_detected_at < ? OR "
837
+ "(v.last_detected_at = ? AND v.violation_id < ?))))",
838
+ position[0],
839
+ position[0],
840
+ position[1],
841
+ position[1],
842
+ position[2],
843
+ )
844
+ order = "v.severity_rank DESC, v.last_detected_at DESC, v.violation_id DESC"
845
+ elif sort == "repository":
846
+ position = decode_cursor(cursor, (str, float, str))
847
+ name_expr = (
848
+ "COALESCE((SELECT owner || '/' || name FROM known_repositories k WHERE "
849
+ "k.installation_id = v.installation_id AND k.repository_id = v.repository_id), '')"
850
+ )
851
+ if position is not None:
852
+ where.add(
853
+ " ".join(
854
+ (
855
+ "(",
856
+ name_expr,
857
+ "> ? OR (",
858
+ name_expr,
859
+ "= ? AND (v.last_detected_at < ? "
860
+ "OR (v.last_detected_at = ? AND v.violation_id < ?))))",
861
+ )
862
+ ),
863
+ position[0],
864
+ position[0],
865
+ position[1],
866
+ position[1],
867
+ position[2],
868
+ )
869
+ order = " ".join((name_expr, "ASC, v.last_detected_at DESC, v.violation_id DESC"))
870
+ else:
871
+ newest = sort == "newest"
872
+ position = decode_cursor(cursor, (float, str))
873
+ if position is not None:
874
+ where.add(
875
+ "(v.last_detected_at < ? OR (v.last_detected_at = ? AND v.violation_id < ?))"
876
+ if newest
877
+ else (
878
+ "(v.last_detected_at > ? OR (v.last_detected_at = ? "
879
+ "AND v.violation_id > ?))"
880
+ ),
881
+ position[0],
882
+ position[0],
883
+ position[1],
884
+ )
885
+ order = (
886
+ "v.last_detected_at DESC, v.violation_id DESC"
887
+ if newest
888
+ else "v.last_detected_at ASC, v.violation_id ASC"
889
+ )
890
+ rows = self._store.query(
891
+ " ".join(
892
+ (
893
+ "SELECT",
894
+ _VIOLATION_COLUMNS,
895
+ "FROM violations v WHERE",
896
+ where.sql,
897
+ "ORDER BY",
898
+ order,
899
+ "LIMIT ?",
900
+ )
901
+ ),
902
+ (*where.params, limit + 1),
903
+ )
904
+ items = [violation_summary(r) for r in rows[:limit]]
905
+ next_cursor = None
906
+ if len(rows) > limit:
907
+ last = rows[limit - 1]
908
+ if sort == "severity":
909
+ next_cursor = encode_cursor(
910
+ [last["severity_rank"], last["last_detected_at"], last["violation_id"]]
911
+ )
912
+ elif sort == "repository":
913
+ next_cursor = encode_cursor(
914
+ [last["full_name"] or "", last["last_detected_at"], last["violation_id"]]
915
+ )
916
+ else:
917
+ next_cursor = encode_cursor([last["last_detected_at"], last["violation_id"]])
918
+ return Page(items, next_cursor, limit)
919
+
920
+ def violation_row(self, scope: AccessScope, violation_id: str) -> Row | None:
921
+ if not scope.installation_ids or not _is_hex_id(violation_id):
922
+ return None
923
+ rows = self._store.query(
924
+ " ".join(
925
+ (
926
+ "SELECT",
927
+ _VIOLATION_COLUMNS,
928
+ "FROM violations v WHERE v.violation_id = ? AND",
929
+ SCOPE_VIOLATIONS,
930
+ )
931
+ ),
932
+ (violation_id, scope.installations_json, scope.session_hash),
933
+ )
934
+ return rows[0] if rows else None
935
+
936
+ def get_violation(
937
+ self, scope: AccessScope, violation_id: str, *, principal: Principal
938
+ ) -> ViolationDetail | None:
939
+ row = self.violation_row(scope, violation_id)
940
+ if row is None:
941
+ return None
942
+ latest = self._store.query(
943
+ "SELECT * FROM findings WHERE violation_id = ? ORDER BY finding_id DESC LIMIT 1",
944
+ (violation_id,),
945
+ )
946
+ exposures = self._store.query(
947
+ "SELECT kind, label, active, opened_at, closed_at, closed_reason FROM "
948
+ "violation_exposures WHERE violation_id = ? ORDER BY active DESC, opened_at DESC "
949
+ "LIMIT 100",
950
+ (violation_id,),
951
+ )
952
+ detections = self._store.query(
953
+ "SELECT f.job_id, f.action, f.created_at, j.state, j.result_action, j.head_sha "
954
+ "FROM findings f JOIN scan_jobs j ON j.job_id = f.job_id WHERE f.violation_id = ? "
955
+ "ORDER BY f.finding_id DESC LIMIT 50",
956
+ (violation_id,),
957
+ )
958
+ finding = latest[0] if latest else None
959
+ acknowledgement = None
960
+ if row["acknowledged_at"] is not None:
961
+ acknowledgement = AcknowledgementView(
962
+ by=row["acknowledged_by_login"],
963
+ at=_req_dt(row["acknowledged_at"]),
964
+ note=row["acknowledgement_note"],
965
+ )
966
+ account_id = row["account_id"]
967
+ return ViolationDetail(
968
+ violation=violation_summary(row),
969
+ detector=row["detector"],
970
+ message=finding["message"] if finding else row["title"],
971
+ committer=finding["committer"] if finding else None,
972
+ policy_reason=finding["reason"] if finding else "",
973
+ evidence=_evidence(finding["evidence"]) if finding else (),
974
+ remediation=finding["remediation"] if finding else "",
975
+ recommended_steps=remediation_steps(row["rule_id"]),
976
+ resolved_at=_dt(row["resolved_at"]),
977
+ resolution=row["resolution"],
978
+ acknowledgement=acknowledgement,
979
+ exposures=tuple(
980
+ ExposureView(
981
+ kind=e["kind"],
982
+ label=e["label"],
983
+ active=bool(e["active"]),
984
+ opened_at=_req_dt(e["opened_at"]),
985
+ closed_at=_dt(e["closed_at"]),
986
+ closed_reason=e["closed_reason"],
987
+ )
988
+ for e in exposures
989
+ ),
990
+ detections=tuple(
991
+ DetectionView(
992
+ scan=d["job_id"],
993
+ result=ScanResultStatus(scan_result_label(d["state"], d["result_action"])),
994
+ detected_at=_req_dt(d["created_at"]),
995
+ action=Action(d["action"]),
996
+ head_sha=d["head_sha"],
997
+ )
998
+ for d in detections
999
+ ),
1000
+ first_scan_id=row["first_job_id"],
1001
+ last_scan_id=row["last_job_id"],
1002
+ can_manage=account_id is not None
1003
+ and principal.can(Permission.VIOLATIONS_MANAGE, int(account_id))
1004
+ and row["status"] == "open",
1005
+ )
1006
+
1007
+ # ------------------------------------------------------------------ #
1008
+ # Repositories
1009
+ # ------------------------------------------------------------------ #
1010
+ def _repository_rows(
1011
+ self,
1012
+ scope: AccessScope,
1013
+ *,
1014
+ organization_id: int | None = None,
1015
+ repository_id: int | None = None,
1016
+ q: str | None = None,
1017
+ ) -> list[_RepoRow]:
1018
+ if not scope.installation_ids:
1019
+ return []
1020
+ where = _Where(SCOPE_REPOSITORIES, scope.installations_json, scope.session_hash)
1021
+ if organization_id is not None:
1022
+ where.add("i.account_id = ?", organization_id)
1023
+ if repository_id is not None:
1024
+ where.add("r.repository_id = ?", repository_id)
1025
+ if q is not None:
1026
+ where.add("(r.owner || '/' || r.name) LIKE ? ESCAPE '\\'", like_pattern(q))
1027
+ sql = " ".join(
1028
+ (
1029
+ "SELECT r.installation_id, r.repository_id, r.owner, r.name, r.default_branch, "
1030
+ "r.removed_at, i.account_id, i.account_login, i.account_type, i.state AS "
1031
+ "installation_state, EXISTS (SELECT 1 FROM installation_repositories ir WHERE "
1032
+ "ir.installation_id = r.installation_id AND ir.repository_id = r.repository_id) "
1033
+ "AS listed, COALESCE((SELECT monitoring_enabled FROM repository_settings s WHERE "
1034
+ "s.installation_id = r.installation_id AND s.repository_id = r.repository_id), 1) "
1035
+ "AS monitoring_enabled, (SELECT MAX(sequence) FROM scan_jobs j WHERE "
1036
+ "j.installation_id = r.installation_id AND j.repository_id = r.repository_id) "
1037
+ "AS latest_sequence, (SELECT failure_kind FROM scan_jobs j WHERE "
1038
+ "j.installation_id = r.installation_id AND j.repository_id = r.repository_id AND "
1039
+ "j.state IN ('passed', 'failed', 'error') ORDER BY sequence DESC LIMIT 1) AS "
1040
+ "latest_failure_kind, (SELECT COUNT(*) FROM violations v WHERE "
1041
+ "v.installation_id = r.installation_id AND v.repository_id = r.repository_id AND "
1042
+ "v.status = 'open' AND v.action = 'block') AS open_violations, (SELECT COUNT(*) "
1043
+ "FROM violations v WHERE v.installation_id = r.installation_id AND "
1044
+ "v.repository_id = r.repository_id AND v.status = 'open' AND v.action = 'warn') "
1045
+ "AS open_warnings, (SELECT COUNT(*) FROM violations v WHERE "
1046
+ "v.installation_id = r.installation_id AND v.repository_id = r.repository_id AND "
1047
+ "v.status = 'open' AND v.severity = 'critical') AS critical_open, "
1048
+ "e.branch_protection, e.branch_protection_detail, e.required_checks, e.branch, "
1049
+ "e.actions, e.actions_detail, e.checked_at, e.merge_queue, e.merge_queue_detail "
1050
+ "FROM known_repositories r JOIN installations i ON i.installation_id = "
1051
+ "r.installation_id LEFT JOIN enforcement_status e ON e.installation_id = "
1052
+ "r.installation_id AND e.repository_id = r.repository_id WHERE",
1053
+ where.sql,
1054
+ "LIMIT 20000",
1055
+ )
1056
+ )
1057
+ rows = self._store.query(sql, where.params)
1058
+ sequences = [r["latest_sequence"] for r in rows if r["latest_sequence"] is not None]
1059
+ latest: dict[int, Row] = {}
1060
+ for chunk_start in range(0, len(sequences), 500):
1061
+ chunk = sequences[chunk_start : chunk_start + 500]
1062
+ for scan in self._store.query(
1063
+ " ".join(
1064
+ (
1065
+ "SELECT",
1066
+ _SCAN_COLUMNS,
1067
+ "FROM scan_jobs j WHERE j.sequence IN (SELECT value FROM json_each(?))",
1068
+ )
1069
+ ),
1070
+ (json.dumps(chunk),),
1071
+ ):
1072
+ latest[int(scan["sequence"])] = scan
1073
+ results = []
1074
+ for row in rows:
1075
+ scan_row = latest.get(row["latest_sequence"]) if row["latest_sequence"] else None
1076
+ results.append(_RepoRow(row, self._repository_summary(row, scan_row)))
1077
+ return results
1078
+
1079
+ @staticmethod
1080
+ def _app_connection(row: Row) -> AppConnection:
1081
+ if row["installation_state"] == "suspended":
1082
+ return AppConnection.SUSPENDED
1083
+ if row["installation_state"] != "active" or not row["listed"]:
1084
+ return AppConnection.DISCONNECTED
1085
+ return AppConnection.CONNECTED
1086
+
1087
+ def _repository_summary(self, row: Row, scan_row: Row | None) -> RepositorySummary:
1088
+ app = self._app_connection(row)
1089
+ monitoring = bool(row["monitoring_enabled"])
1090
+ protection, reason = protection_for(
1091
+ app=app,
1092
+ monitoring_enabled=monitoring,
1093
+ latest_failure_kind=row["latest_failure_kind"],
1094
+ branch_protection=row["branch_protection"],
1095
+ detail=row["branch_protection_detail"],
1096
+ )
1097
+ owner, name = row["owner"], row["name"]
1098
+ return RepositorySummary(
1099
+ id=row["repository_id"],
1100
+ installation_id=row["installation_id"],
1101
+ organization=OrganizationRef(
1102
+ id=row["account_id"], login=row["account_login"], type=row["account_type"]
1103
+ ),
1104
+ owner=owner,
1105
+ name=name,
1106
+ full_name=f"{owner}/{name}",
1107
+ # owner and name are validated GitHub identifiers ([A-Za-z0-9._-]).
1108
+ github_url=f"https://github.com/{owner}/{name}",
1109
+ default_branch=row["default_branch"],
1110
+ protection=protection,
1111
+ protection_reason=reason,
1112
+ app_connection=app,
1113
+ monitoring_enabled=monitoring,
1114
+ last_scan=scan_summary(scan_row) if scan_row is not None else None,
1115
+ open_violations=row["open_violations"],
1116
+ open_warnings=row["open_warnings"],
1117
+ critical_open=row["critical_open"],
1118
+ )
1119
+
1120
+ def repository_summaries(
1121
+ self, scope: AccessScope, *, organization_id: int | None = None
1122
+ ) -> list[tuple[Row, RepositorySummary]]:
1123
+ """Every repository visible in ``scope`` with its summary (organization views)."""
1124
+ return [
1125
+ (r.row, r.summary)
1126
+ for r in self._repository_rows(scope, organization_id=organization_id)
1127
+ ]
1128
+
1129
+ def list_repositories(
1130
+ self, scope: AccessScope, filters: RepositoryFilters, *, offset: int, limit: int
1131
+ ) -> Page[RepositorySummary]:
1132
+ rows = self._repository_rows(scope, organization_id=filters.organization_id, q=filters.q)
1133
+ summaries = [r.summary for r in rows]
1134
+ if filters.protection is not None:
1135
+ summaries = [s for s in summaries if s.protection is filters.protection]
1136
+ summaries = sort_repositories(summaries, filters.sort)
1137
+ page = summaries[offset : offset + limit]
1138
+ next_cursor = encode_cursor([offset + limit]) if len(summaries) > offset + limit else None
1139
+ return Page(page, next_cursor, limit)
1140
+
1141
+ def repository_ref(self, scope: AccessScope, repository_id: int) -> Row | None:
1142
+ """The (installation, repository) row a caller can see, preferring a connected one."""
1143
+ rows = self._repository_rows(scope, repository_id=repository_id)
1144
+ if not rows:
1145
+ return None
1146
+ rows.sort(
1147
+ key=lambda r: (
1148
+ r.summary.app_connection is AppConnection.CONNECTED,
1149
+ r.row["latest_sequence"] or 0,
1150
+ ),
1151
+ reverse=True,
1152
+ )
1153
+ return rows[0].row
1154
+
1155
+ def get_repository(
1156
+ self, scope: AccessScope, repository_id: int, *, principal: Principal
1157
+ ) -> RepositoryDetail | None:
1158
+ rows = self._repository_rows(scope, repository_id=repository_id)
1159
+ if not rows:
1160
+ return None
1161
+ rows.sort(
1162
+ key=lambda r: (
1163
+ r.summary.app_connection is AppConnection.CONNECTED,
1164
+ r.row["latest_sequence"] or 0,
1165
+ ),
1166
+ reverse=True,
1167
+ )
1168
+ chosen = rows[0]
1169
+ row, summary = chosen.row, chosen.summary
1170
+ installation_id = row["installation_id"]
1171
+ account_id = int(row["account_id"])
1172
+ recent = self._store.query(
1173
+ " ".join(
1174
+ (
1175
+ "SELECT",
1176
+ _SCAN_COLUMNS,
1177
+ "FROM scan_jobs j WHERE j.installation_id = ? AND "
1178
+ "j.repository_id = ? ORDER BY j.sequence DESC LIMIT 10",
1179
+ )
1180
+ ),
1181
+ (installation_id, repository_id),
1182
+ )
1183
+ completed = self._store.query(
1184
+ "SELECT job_id, effective_policies, organization_policy_version, state, "
1185
+ "result_action, head_sha, completed_at FROM scan_jobs WHERE installation_id = ? AND "
1186
+ "repository_id = ? AND state IN ('passed', 'failed') ORDER BY sequence DESC LIMIT 1",
1187
+ (installation_id, repository_id),
1188
+ )
1189
+ latest_completed = completed[0] if completed else None
1190
+ violations = self._store.query(
1191
+ " ".join(
1192
+ (
1193
+ "SELECT",
1194
+ _VIOLATION_COLUMNS,
1195
+ "FROM violations v WHERE v.installation_id = ? AND "
1196
+ "v.repository_id = ? AND v.status = 'open' ORDER BY v.severity_rank DESC, "
1197
+ "v.last_detected_at DESC LIMIT 10",
1198
+ )
1199
+ ),
1200
+ (installation_id, repository_id),
1201
+ )
1202
+ can_audit = principal.can(Permission.AUDIT_READ, account_id)
1203
+ audit: tuple[AuditEventView, ...] = ()
1204
+ if can_audit:
1205
+ events = self._store.query(
1206
+ "SELECT document FROM audit_events WHERE installation_id = ? AND repository_id = ? "
1207
+ "ORDER BY occurred_at DESC LIMIT 10",
1208
+ (installation_id, repository_id),
1209
+ )
1210
+ names = {(installation_id, repository_id): summary.full_name}
1211
+ audit = tuple(
1212
+ audit_view(AuditEvent.model_validate_json(e["document"]), names) for e in events
1213
+ )
1214
+ checked_at = _dt(row["checked_at"])
1215
+ enforcement = EnforcementView(
1216
+ github_app=EnforcementSignal(
1217
+ status=summary.app_connection.value,
1218
+ detail={
1219
+ AppConnection.CONNECTED: "The GitHub App can access this repository.",
1220
+ AppConnection.SUSPENDED: "The GitHub App installation is suspended.",
1221
+ AppConnection.DISCONNECTED: "The GitHub App no longer has access.",
1222
+ }[summary.app_connection],
1223
+ ),
1224
+ github_actions=EnforcementSignal(
1225
+ status=row["actions"] or "unknown",
1226
+ detail=row["actions_detail"] or "Not checked yet.",
1227
+ checked_at=checked_at,
1228
+ ),
1229
+ required_check=RequiredCheckSignal(
1230
+ status=RequiredCheckStatus(row["branch_protection"] or "unknown"),
1231
+ branch=row["branch"],
1232
+ required_checks=tuple(json.loads(row["required_checks"] or "[]")),
1233
+ detail=row["branch_protection_detail"] or "Not checked yet.",
1234
+ checked_at=checked_at,
1235
+ ),
1236
+ latest_check=LatestCheckSignal(
1237
+ result=ScanResultStatus(
1238
+ scan_result_label(latest_completed["state"], latest_completed["result_action"])
1239
+ )
1240
+ if latest_completed
1241
+ else None,
1242
+ scan=latest_completed["job_id"] if latest_completed else None,
1243
+ head_sha=latest_completed["head_sha"] if latest_completed else None,
1244
+ completed_at=_dt(latest_completed["completed_at"]) if latest_completed else None,
1245
+ ),
1246
+ local_hooks=EnforcementSignal(
1247
+ status="not_verifiable",
1248
+ detail="A server cannot see whether developers installed the Git hooks.",
1249
+ ),
1250
+ merge_queue=EnforcementSignal(
1251
+ status=row["merge_queue"] or MergeQueueStatus.UNKNOWN.value,
1252
+ detail=row["merge_queue_detail"] or "Not checked yet.",
1253
+ checked_at=checked_at,
1254
+ ),
1255
+ monitoring_enabled=summary.monitoring_enabled,
1256
+ )
1257
+ return RepositoryDetail(
1258
+ repository=summary,
1259
+ enforcement=enforcement,
1260
+ organization_policy_version=latest_completed["organization_policy_version"]
1261
+ if latest_completed
1262
+ else None,
1263
+ effective_policies=_policies(latest_completed["effective_policies"])
1264
+ if latest_completed
1265
+ else (),
1266
+ effective_policy_scan=latest_completed["job_id"] if latest_completed else None,
1267
+ recent_scans=tuple(scan_summary(r) for r in recent),
1268
+ open_violations=tuple(violation_summary(v) for v in violations),
1269
+ audit=audit,
1270
+ permissions=RepositoryPermissions(
1271
+ manage=principal.can(Permission.REPOSITORIES_MANAGE, account_id),
1272
+ trigger_scans=principal.can(Permission.SCANS_TRIGGER, account_id),
1273
+ read_audit=can_audit,
1274
+ ),
1275
+ )
1276
+
1277
+ def merge_queue(self, scope: AccessScope, repository_id: int) -> MergeQueueView | None:
1278
+ rows = self._repository_rows(scope, repository_id=repository_id)
1279
+ if not rows:
1280
+ return None
1281
+ rows.sort(
1282
+ key=lambda r: (
1283
+ r.summary.app_connection is AppConnection.CONNECTED,
1284
+ r.row["latest_sequence"] or 0,
1285
+ ),
1286
+ reverse=True,
1287
+ )
1288
+ row = rows[0].row
1289
+ installation_id = int(row["installation_id"])
1290
+ records = self._store.list_merge_groups(installation_id, repository_id, limit=10)
1291
+ jobs: dict[str, Row] = {}
1292
+ ids = [r.job_id for r in records if r.job_id]
1293
+ if ids:
1294
+ for job in self._store.query(
1295
+ " ".join(
1296
+ (
1297
+ "SELECT",
1298
+ _SCAN_COLUMNS,
1299
+ "FROM scan_jobs j WHERE j.job_id IN (SELECT value FROM json_each(?))",
1300
+ )
1301
+ ),
1302
+ (json.dumps(ids),),
1303
+ ):
1304
+ jobs[job["job_id"]] = job
1305
+ views = [merge_group_view(r, jobs.get(r.job_id or "")) for r in records]
1306
+ current = next((v for v in views if v.state == "checks_requested"), None)
1307
+ installation = self._store.get_installation(installation_id)
1308
+ permission: Literal["granted", "missing"] = (
1309
+ "granted"
1310
+ if installation is not None
1311
+ and level_rank(installation.permissions.get("merge_queues")) >= level_rank("read")
1312
+ else "missing"
1313
+ )
1314
+ status = MergeQueueStatus(row["merge_queue"] or MergeQueueStatus.UNKNOWN.value)
1315
+ return MergeQueueView(
1316
+ repository_id=repository_id,
1317
+ status=status,
1318
+ detail=row["merge_queue_detail"]
1319
+ or "Merge queue settings have not been checked. Refresh the enforcement status.",
1320
+ checked_at=_dt(row["checked_at"]),
1321
+ permission=permission,
1322
+ current=current,
1323
+ recent=tuple(views),
1324
+ )
1325
+
1326
+ # ------------------------------------------------------------------ #
1327
+ # Audit
1328
+ # ------------------------------------------------------------------ #
1329
+ def list_audit(
1330
+ self, scope: AccessScope, filters: AuditFilters, *, cursor: str | None, limit: int
1331
+ ) -> Page[AuditEventView]:
1332
+ if not scope.account_ids:
1333
+ return Page([], None, limit)
1334
+ where = _Where(
1335
+ SCOPE_AUDIT, scope.accounts_json, scope.installations_json, scope.session_hash
1336
+ )
1337
+ if filters.organization_id is not None:
1338
+ where.add("a.account_id = ?", filters.organization_id)
1339
+ if filters.repository_id is not None:
1340
+ where.add("a.repository_id = ?", filters.repository_id)
1341
+ if filters.event_type is not None:
1342
+ where.add("a.type = ?", filters.event_type.value)
1343
+ if filters.actor is not None:
1344
+ where.add("a.actor_login LIKE ? ESCAPE '\\'", like_pattern(filters.actor))
1345
+ if filters.start is not None:
1346
+ where.add("a.occurred_at >= ?", filters.start.timestamp())
1347
+ if filters.end is not None:
1348
+ where.add("a.occurred_at < ?", filters.end.timestamp())
1349
+ if filters.rule_id is not None:
1350
+ where.add(
1351
+ "(json_extract(a.document, '$.data.rule') = ? OR "
1352
+ "json_extract(a.document, '$.data.rules') = ?)",
1353
+ filters.rule_id,
1354
+ filters.rule_id,
1355
+ )
1356
+ if filters.exception_id is not None:
1357
+ where.add("json_extract(a.document, '$.data.exception') = ?", filters.exception_id)
1358
+ if filters.policy == "organization":
1359
+ where.add(
1360
+ "a.type IN ('organization_policy_changed', 'organization_policy_rolled_back', "
1361
+ "'policy_emergency_published', 'organization_settings_changed')"
1362
+ )
1363
+ elif filters.policy is not None:
1364
+ where.add(
1365
+ "(json_extract(a.document, '$.data.draft') = ? OR "
1366
+ "json_extract(a.document, '$.data.target_id') = ? OR "
1367
+ "json_extract(a.document, '$.data.group') = ? OR "
1368
+ "json_extract(a.document, '$.data.rollout') = ?)",
1369
+ filters.policy,
1370
+ filters.policy,
1371
+ filters.policy,
1372
+ filters.policy,
1373
+ )
1374
+ newest = filters.sort != "oldest"
1375
+ # Ties on the timestamp are broken by insertion order (rowid), so events
1376
+ # recorded in one transaction keep their order.
1377
+ position = decode_cursor(cursor, (float, int))
1378
+ if position is not None:
1379
+ where.add(
1380
+ "(a.occurred_at < ? OR (a.occurred_at = ? AND a.rowid < ?))"
1381
+ if newest
1382
+ else "(a.occurred_at > ? OR (a.occurred_at = ? AND a.rowid > ?))",
1383
+ position[0],
1384
+ position[0],
1385
+ position[1],
1386
+ )
1387
+ order = "a.occurred_at DESC, a.rowid DESC" if newest else "a.occurred_at ASC, a.rowid ASC"
1388
+ rows = self._store.query(
1389
+ " ".join(
1390
+ (
1391
+ "SELECT a.rowid AS position, a.occurred_at, a.document FROM audit_events a "
1392
+ "WHERE",
1393
+ where.sql,
1394
+ "ORDER BY",
1395
+ order,
1396
+ "LIMIT ?",
1397
+ )
1398
+ ),
1399
+ (*where.params, limit + 1),
1400
+ )
1401
+ events = [AuditEvent.model_validate_json(r["document"]) for r in rows[:limit]]
1402
+ names = self._full_names(events)
1403
+ items = [audit_view(e, names) for e in events]
1404
+ next_cursor = None
1405
+ if len(rows) > limit:
1406
+ last = rows[limit - 1]
1407
+ next_cursor = encode_cursor([last["occurred_at"], last["position"]])
1408
+ return Page(items, next_cursor, limit)
1409
+
1410
+ def get_audit_event(self, scope: AccessScope, event_id: str) -> AuditEventView | None:
1411
+ if not scope.account_ids or not _is_hex_id(event_id):
1412
+ return None
1413
+ rows = self._store.query(
1414
+ " ".join(
1415
+ ("SELECT a.document FROM audit_events a WHERE a.event_id = ? AND", SCOPE_AUDIT)
1416
+ ),
1417
+ (event_id, scope.accounts_json, scope.installations_json, scope.session_hash),
1418
+ )
1419
+ if not rows:
1420
+ return None
1421
+ event = AuditEvent.model_validate_json(rows[0]["document"])
1422
+ return audit_view(event, self._full_names([event]))
1423
+
1424
+ def _full_names(self, events: Sequence[AuditEvent]) -> dict[tuple[int, int], str]:
1425
+ keys = sorted(
1426
+ {
1427
+ (e.installation_id, e.repository_id)
1428
+ for e in events
1429
+ if e.installation_id is not None and e.repository_id is not None
1430
+ }
1431
+ )
1432
+ names: dict[tuple[int, int], str] = {}
1433
+ for installation_id, repository_id in keys:
1434
+ rows = self._store.query(
1435
+ "SELECT owner, name FROM known_repositories WHERE installation_id = ? "
1436
+ "AND repository_id = ?",
1437
+ (installation_id, repository_id),
1438
+ )
1439
+ if rows:
1440
+ names[(installation_id, repository_id)] = f"{rows[0]['owner']}/{rows[0]['name']}"
1441
+ return names
1442
+
1443
+ # ------------------------------------------------------------------ #
1444
+ # GitHub installations
1445
+ # ------------------------------------------------------------------ #
1446
+ def _installation_rows(
1447
+ self, scope: AccessScope, installation_id: int | None = None
1448
+ ) -> list[Row]:
1449
+ if not scope.installation_ids:
1450
+ return []
1451
+ where = _Where(SCOPE_INSTALLATIONS, scope.installations_json)
1452
+ if installation_id is not None:
1453
+ where.add("i.installation_id = ?", installation_id)
1454
+ return self._store.query(
1455
+ " ".join(
1456
+ (
1457
+ "SELECT i.*, (SELECT COUNT(*) FROM installation_repositories ir WHERE "
1458
+ "ir.installation_id = i.installation_id) AS repository_count, (SELECT "
1459
+ "MAX(occurred_at) FROM audit_events a WHERE a.installation_id = "
1460
+ "i.installation_id) AS last_event_at FROM installations i WHERE",
1461
+ where.sql,
1462
+ "ORDER BY i.account_login COLLATE NOCASE, i.installation_id",
1463
+ )
1464
+ ),
1465
+ where.params,
1466
+ )
1467
+
1468
+ @staticmethod
1469
+ def _installation_view(row: Row, principal: Principal) -> InstallationView:
1470
+ permissions = json.loads(row["permissions"] or "{}")
1471
+ state = row["state"]
1472
+ status = {
1473
+ "active": AppConnection.CONNECTED,
1474
+ "suspended": AppConnection.SUSPENDED,
1475
+ }.get(state, AppConnection.DISCONNECTED)
1476
+ login = row["account_login"]
1477
+ if row["account_type"] == "Organization":
1478
+ url = f"https://github.com/organizations/{login}/settings/installations/{row['installation_id']}"
1479
+ else:
1480
+ url = f"https://github.com/settings/installations/{row['installation_id']}"
1481
+ return InstallationView(
1482
+ id=row["installation_id"],
1483
+ account=OrganizationRef(id=row["account_id"], login=login, type=row["account_type"]),
1484
+ status=status,
1485
+ repository_selection=row["repository_selection"],
1486
+ repositories=row["repository_count"],
1487
+ permissions=permissions,
1488
+ missing_permissions=tuple(
1489
+ f"{k}: {v}"
1490
+ for k, v in missing_permissions(permissions, REQUIRED_PERMISSIONS).items()
1491
+ ),
1492
+ excessive_permissions=tuple(
1493
+ f"{k}: {v}"
1494
+ for k, v in excessive_permissions(permissions, REQUIRED_PERMISSIONS).items()
1495
+ ),
1496
+ installed_at=_req_dt(row["created_at"]),
1497
+ updated_at=_req_dt(row["updated_at"]),
1498
+ last_event_at=_dt(row["last_event_at"]),
1499
+ github_settings_url=url,
1500
+ can_manage=principal.can(Permission.GITHUB_MANAGE, int(row["account_id"])),
1501
+ )
1502
+
1503
+ def list_installations(
1504
+ self, scope: AccessScope, principal: Principal
1505
+ ) -> list[InstallationView]:
1506
+ return [self._installation_view(r, principal) for r in self._installation_rows(scope)]
1507
+
1508
+ def get_installation(
1509
+ self, scope: AccessScope, installation_id: int, principal: Principal
1510
+ ) -> InstallationDetail | None:
1511
+ rows = self._installation_rows(scope, installation_id)
1512
+ if not rows:
1513
+ return None
1514
+ view = self._installation_view(rows[0], principal)
1515
+ events: tuple[AuditEventView, ...] = ()
1516
+ if principal.can(Permission.AUDIT_READ, view.account.id):
1517
+ audit_scope = principal.scope(Permission.AUDIT_READ, account_id=view.account.id)
1518
+ page = self.list_audit(
1519
+ audit_scope,
1520
+ AuditFilters(organization_id=view.account.id),
1521
+ cursor=None,
1522
+ limit=10,
1523
+ )
1524
+ events = tuple(e for e in page.items if e.installation_id in (installation_id, None))
1525
+ return InstallationDetail(installation=view, recent_events=events)
1526
+
1527
+ def installation_repositories(
1528
+ self, scope: AccessScope, installation_id: int, *, offset: int, limit: int
1529
+ ) -> Page[InstallationRepositoryView] | None:
1530
+ if not self._installation_rows(scope, installation_id):
1531
+ return None
1532
+ rows = self._store.query(
1533
+ "SELECT r.repository_id, r.owner, r.name, r.first_seen_at, r.removed_at, EXISTS "
1534
+ "(SELECT 1 FROM installation_repositories ir WHERE ir.installation_id = "
1535
+ "r.installation_id AND ir.repository_id = r.repository_id) AS listed, "
1536
+ "COALESCE((SELECT monitoring_enabled FROM repository_settings s WHERE "
1537
+ "s.installation_id = r.installation_id AND s.repository_id = r.repository_id), 1) AS "
1538
+ "monitoring_enabled FROM known_repositories r WHERE r.installation_id = ? AND EXISTS "
1539
+ "(SELECT 1 FROM session_repositories sr WHERE sr.session_hash = ? AND "
1540
+ "sr.installation_id = r.installation_id AND sr.repository_id = r.repository_id) "
1541
+ "ORDER BY listed DESC, r.owner COLLATE NOCASE, r.name COLLATE NOCASE LIMIT ? OFFSET ?",
1542
+ (installation_id, scope.session_hash, limit + 1, offset),
1543
+ )
1544
+ items = [
1545
+ InstallationRepositoryView(
1546
+ id=r["repository_id"],
1547
+ full_name=f"{r['owner']}/{r['name']}",
1548
+ connected=bool(r["listed"]),
1549
+ monitoring_enabled=bool(r["monitoring_enabled"]),
1550
+ added_at=_dt(r["first_seen_at"]),
1551
+ removed_at=_dt(r["removed_at"]),
1552
+ )
1553
+ for r in rows[:limit]
1554
+ ]
1555
+ next_cursor = encode_cursor([offset + limit]) if len(rows) > limit else None
1556
+ return Page(items, next_cursor, limit)
1557
+
1558
+ # ------------------------------------------------------------------ #
1559
+ # Overview
1560
+ # ------------------------------------------------------------------ #
1561
+ def overview(
1562
+ self,
1563
+ principal: Principal,
1564
+ *,
1565
+ period: str,
1566
+ organization_id: int | None,
1567
+ ) -> OverviewView:
1568
+ now = self._now()
1569
+ start = now - PERIODS[period]
1570
+ repo_scope = principal.scope(Permission.REPOSITORIES_READ, account_id=organization_id)
1571
+ scan_scope = principal.scope(Permission.SCANS_READ, account_id=organization_id)
1572
+ violation_scope = principal.scope(Permission.VIOLATIONS_READ, account_id=organization_id)
1573
+
1574
+ repositories = [r.summary for r in self._repository_rows(repo_scope)]
1575
+ monitored = [r for r in repositories if r.app_connection is AppConnection.CONNECTED]
1576
+ by_protection = {
1577
+ status: sum(1 for r in monitored if r.protection is status)
1578
+ for status in ProtectionStatus
1579
+ }
1580
+ scan_counts = {"total": 0, "passed": 0, "blocked": 0, "error": 0}
1581
+ if scan_scope.installation_ids:
1582
+ row = self._store.query(
1583
+ " ".join(
1584
+ (
1585
+ "SELECT COUNT(*) AS total, SUM(j.state = 'passed') AS passed, "
1586
+ "SUM(j.state = 'failed') AS blocked, SUM(j.state = 'error') AS error "
1587
+ "FROM scan_jobs j WHERE",
1588
+ SCOPE_JOBS,
1589
+ "AND j.created_at >= ?",
1590
+ )
1591
+ ),
1592
+ (scan_scope.installations_json, scan_scope.session_hash, start.timestamp()),
1593
+ )[0]
1594
+ scan_counts = {k: int(row[k] or 0) for k in scan_counts}
1595
+ violation_counts = {"block": 0, "warn": 0, "critical": 0, "high": 0}
1596
+ if violation_scope.installation_ids:
1597
+ row = self._store.query(
1598
+ " ".join(
1599
+ (
1600
+ "SELECT SUM(v.action = 'block') AS block, SUM(v.action = 'warn') AS warn, "
1601
+ "SUM(v.severity = 'critical') AS critical, "
1602
+ "SUM(v.severity = 'high') AS high "
1603
+ "FROM violations v WHERE",
1604
+ SCOPE_VIOLATIONS,
1605
+ "AND v.status = 'open'",
1606
+ )
1607
+ ),
1608
+ (violation_scope.installations_json, violation_scope.session_hash),
1609
+ )[0]
1610
+ violation_counts = {k: int(row[k] or 0) for k in violation_counts}
1611
+
1612
+ installations = self.list_installations(repo_scope, principal)
1613
+ integration = _integration(installations)
1614
+ recent_scans = self.list_scans(scan_scope, ScanFilters(), cursor=None, limit=8).items
1615
+ recent_violations = self.list_violations(
1616
+ violation_scope, ViolationFilters(), cursor=None, limit=8
1617
+ ).items
1618
+ health_list = sort_repositories(repositories, "risk")[:8]
1619
+ summary = OverviewSummary(
1620
+ repositories_monitored=len(monitored),
1621
+ repositories_protected=by_protection[ProtectionStatus.PROTECTED],
1622
+ # Not "monitored": the App lost access, which is exactly what puts them at risk.
1623
+ repositories_at_risk=sum(
1624
+ 1 for r in repositories if r.protection is ProtectionStatus.AT_RISK
1625
+ ),
1626
+ repositories_unprotected=by_protection[ProtectionStatus.UNPROTECTED],
1627
+ repositories_unknown=by_protection[ProtectionStatus.UNKNOWN],
1628
+ repositories_configuration_error=by_protection[ProtectionStatus.CONFIGURATION_ERROR],
1629
+ scans=scan_counts["total"],
1630
+ scans_passed=scan_counts["passed"],
1631
+ scans_blocked=scan_counts["blocked"],
1632
+ scans_error=scan_counts["error"],
1633
+ open_violations=violation_counts["block"],
1634
+ open_warnings=violation_counts["warn"],
1635
+ critical_open=violation_counts["critical"],
1636
+ high_open=violation_counts["high"],
1637
+ )
1638
+ return OverviewView(
1639
+ period=OverviewPeriod(key=period, start=start, end=now), # type: ignore[arg-type]
1640
+ summary=summary,
1641
+ integration=integration,
1642
+ health=_health_checks(summary, integration, len(monitored)),
1643
+ recent_scans=tuple(recent_scans),
1644
+ recent_violations=tuple(recent_violations),
1645
+ repository_health=tuple(health_list),
1646
+ )
1647
+
1648
+
1649
+ def _is_hex_id(value: str) -> bool:
1650
+ return len(value) == 32 and all(c in "0123456789abcdef" for c in value)
1651
+
1652
+
1653
+ _RISK_ORDER = {
1654
+ ProtectionStatus.AT_RISK: 0,
1655
+ ProtectionStatus.CONFIGURATION_ERROR: 1,
1656
+ ProtectionStatus.UNPROTECTED: 2,
1657
+ ProtectionStatus.UNKNOWN: 3,
1658
+ ProtectionStatus.PROTECTED: 4,
1659
+ }
1660
+
1661
+
1662
+ def sort_repositories(items: list[RepositorySummary], sort: str) -> list[RepositorySummary]:
1663
+ if sort == "risk":
1664
+ return sorted(
1665
+ items,
1666
+ key=lambda r: (
1667
+ -r.critical_open,
1668
+ -(1 if r.last_scan and r.last_scan.result is ScanResultStatus.BLOCKED else 0),
1669
+ -r.open_violations,
1670
+ _RISK_ORDER[r.protection],
1671
+ r.full_name.lower(),
1672
+ ),
1673
+ )
1674
+ if sort == "recent":
1675
+ return sorted(
1676
+ items,
1677
+ key=lambda r: (
1678
+ -(r.last_scan.created_at.timestamp() if r.last_scan else 0),
1679
+ r.full_name.lower(),
1680
+ ),
1681
+ )
1682
+ return sorted(items, key=lambda r: (r.full_name.lower(), r.installation_id))
1683
+
1684
+
1685
+ def _integration(installations: Sequence[InstallationView]) -> IntegrationView:
1686
+ connected = sum(1 for i in installations if i.status is AppConnection.CONNECTED)
1687
+ suspended = sum(1 for i in installations if i.status is AppConnection.SUSPENDED)
1688
+ disconnected = sum(1 for i in installations if i.status is AppConnection.DISCONNECTED)
1689
+ missing = any(
1690
+ i.missing_permissions for i in installations if i.status is AppConnection.CONNECTED
1691
+ )
1692
+ if connected and not suspended and not missing:
1693
+ return IntegrationView(
1694
+ status=IntegrationStatus.CONNECTED,
1695
+ installations_connected=connected,
1696
+ installations_suspended=suspended,
1697
+ installations_disconnected=disconnected,
1698
+ detail="The GitHub App is installed and has the permissions CommitGuard needs.",
1699
+ )
1700
+ if connected or suspended:
1701
+ detail = (
1702
+ "An installation is suspended."
1703
+ if suspended
1704
+ else "An installation is missing permissions."
1705
+ )
1706
+ return IntegrationView(
1707
+ status=IntegrationStatus.ACTION_REQUIRED,
1708
+ installations_connected=connected,
1709
+ installations_suspended=suspended,
1710
+ installations_disconnected=disconnected,
1711
+ detail=detail,
1712
+ )
1713
+ return IntegrationView(
1714
+ status=IntegrationStatus.DISCONNECTED,
1715
+ installations_connected=0,
1716
+ installations_suspended=0,
1717
+ installations_disconnected=disconnected,
1718
+ detail="No active GitHub App installation is available to you.",
1719
+ )
1720
+
1721
+
1722
+ def _health_checks(
1723
+ summary: OverviewSummary, integration: IntegrationView, monitored: int
1724
+ ) -> tuple[HealthCheck, ...]:
1725
+ """Explicit checks instead of a score (see docs/dashboard.md, "Security health")."""
1726
+ checks = [
1727
+ HealthCheck(
1728
+ id="github_integration",
1729
+ label="GitHub integration",
1730
+ status=HealthCheckStatus.OK
1731
+ if integration.status is IntegrationStatus.CONNECTED
1732
+ else HealthCheckStatus.ATTENTION,
1733
+ detail=integration.detail,
1734
+ ),
1735
+ ]
1736
+ if monitored == 0:
1737
+ checks.append(
1738
+ HealthCheck(
1739
+ id="required_check",
1740
+ label="Merge protection",
1741
+ status=HealthCheckStatus.UNKNOWN,
1742
+ detail="No repositories are monitored.",
1743
+ )
1744
+ )
1745
+ else:
1746
+ if (
1747
+ summary.repositories_at_risk
1748
+ or summary.repositories_unprotected
1749
+ or summary.repositories_configuration_error
1750
+ ):
1751
+ status = HealthCheckStatus.ATTENTION
1752
+ elif summary.repositories_unknown:
1753
+ status = HealthCheckStatus.UNKNOWN
1754
+ else:
1755
+ status = HealthCheckStatus.OK
1756
+ checks.append(
1757
+ HealthCheck(
1758
+ id="required_check",
1759
+ label="Merge protection",
1760
+ status=status,
1761
+ detail=(
1762
+ f"{summary.repositories_protected} of {monitored} monitored repositories are "
1763
+ "verified to require a CommitGuard check; "
1764
+ f"{summary.repositories_unknown} not verified"
1765
+ + (
1766
+ f"; {summary.repositories_at_risk} at risk (GitHub App access lost)."
1767
+ if summary.repositories_at_risk
1768
+ else "."
1769
+ )
1770
+ ),
1771
+ )
1772
+ )
1773
+ checks.append(
1774
+ HealthCheck(
1775
+ id="critical_violations",
1776
+ label="Critical violations",
1777
+ status=HealthCheckStatus.OK
1778
+ if summary.critical_open == 0
1779
+ else HealthCheckStatus.ATTENTION,
1780
+ detail=f"{summary.critical_open} open critical violation(s).",
1781
+ )
1782
+ )
1783
+ checks.append(
1784
+ HealthCheck(
1785
+ id="open_violations",
1786
+ label="Open violations",
1787
+ status=HealthCheckStatus.OK
1788
+ if summary.open_violations == 0
1789
+ else HealthCheckStatus.ATTENTION,
1790
+ detail=f"{summary.open_violations} blocking violation(s) are currently present.",
1791
+ )
1792
+ )
1793
+ checks.append(
1794
+ HealthCheck(
1795
+ id="scan_errors",
1796
+ label="Scan reliability",
1797
+ status=HealthCheckStatus.OK
1798
+ if summary.scans_error == 0
1799
+ else HealthCheckStatus.ATTENTION,
1800
+ detail=f"{summary.scans_error} scan(s) could not be completed in this period.",
1801
+ )
1802
+ )
1803
+ checks.append(
1804
+ HealthCheck(
1805
+ id="configuration",
1806
+ label="Configuration",
1807
+ status=HealthCheckStatus.OK
1808
+ if summary.repositories_configuration_error == 0
1809
+ else HealthCheckStatus.ATTENTION,
1810
+ detail=f"{summary.repositories_configuration_error} repositories have an invalid "
1811
+ "CommitGuard configuration.",
1812
+ )
1813
+ )
1814
+ return tuple(checks)