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,1272 @@
1
+ """Organization security posture, repository matrix, drift, trends, reports and search.
2
+
3
+ Posture is a small set of explicit states, never a score
4
+ =========================================================
5
+
6
+ Repository posture - the **first** rule that matches decides:
7
+
8
+ ==== ================================================================== ===============
9
+ # Condition Posture
10
+ ==== ================================================================== ===============
11
+ 1 the GitHub App installation is suspended or lost access ``at_risk``
12
+ 2 CommitGuard monitoring is paused ``unprotected``
13
+ 3 GitHub does not require a CommitGuard check (branch protection) ``unprotected``
14
+ 4 the effective policy could not be resolved (propagation ``error``) ``at_risk``
15
+ 5 the latest scan failed on invalid CommitGuard configuration ``at_risk``
16
+ 6 open critical violations ``at_risk``
17
+ 7 monitor mode (violations are reported, not blocked) ``at_risk``
18
+ 8 an active exception lowers a high or critical rule ``at_risk``
19
+ 9 branch protection has not been verified ``unknown``
20
+ 10 otherwise ``secure``
21
+ ==== ================================================================== ===============
22
+
23
+ Organization posture - again the first matching rule decides:
24
+
25
+ * ``unknown`` - the organization has no repositories;
26
+ * ``at_risk`` - any installation suspended, removed or failing to synchronise,
27
+ any repository ``at_risk``, or some (not all) repositories ``unprotected``;
28
+ * ``unprotected`` - every repository is proven ``unprotected`` (monitoring paused
29
+ or no CommitGuard check required by GitHub);
30
+ * ``unknown`` - otherwise, if the protection of any repository is not verified;
31
+ * ``secure`` - every repository is ``secure`` and installations are healthy.
32
+
33
+ **Compliance** is shown only as a fraction with its definition: "*N of M
34
+ required repositories satisfy all mandatory controls*", where required
35
+ repositories are onboarded, not archived and connected, and satisfying means
36
+ posture ``secure``. It is a policy compliance report - not a SOC 2, ISO or any
37
+ other certification, and the reports say so.
38
+
39
+ Policy drift compares what a repository's own configuration asked for with what
40
+ organization governance requires, using the latest scan's recorded provenance
41
+ (the repository configuration is only known at scan time):
42
+
43
+ * ``compliant`` - no conflicts; ``customized`` - the repository changes
44
+ defaults the organization allows it to change; ``drift`` - the repository asks
45
+ for less than a mandatory requirement (the requirement still applies; each
46
+ difference is listed); ``unknown`` - no scan recorded provenance yet.
47
+
48
+ Staleness: computed values carry ``computed_at``. Trends that cannot be derived
49
+ from scan and violation history (protection, exceptions, drift) come from daily
50
+ snapshots written by :meth:`SecurityPostureService.snapshot_metrics`, and say so.
51
+ """
52
+
53
+ import csv
54
+ import io
55
+ import json
56
+ import sqlite3
57
+ from collections.abc import Callable, Mapping, Sequence
58
+ from datetime import UTC, datetime, timedelta
59
+ from typing import Any, Literal
60
+
61
+ from pydantic import BaseModel, ConfigDict
62
+
63
+ from commitguard.audit.models import Actor, AuditEventType
64
+ from commitguard.controlplane.access import Permission, Principal
65
+ from commitguard.controlplane.errors import ConflictError, InputValidationError, NotFoundError
66
+ from commitguard.controlplane.pagination import like_pattern
67
+ from commitguard.controlplane.queries import DashboardQueries
68
+ from commitguard.controlplane.rules import CATALOG
69
+ from commitguard.controlplane.views import AppConnection, ProtectionStatus, RepositorySummary
70
+ from commitguard.core.result import Severity
71
+ from commitguard.github.storage import SqliteStateStore
72
+ from commitguard.governance.common import (
73
+ dt,
74
+ is_hex_id,
75
+ req_dt,
76
+ require,
77
+ text,
78
+ ts,
79
+ visible_repository_ids,
80
+ )
81
+ from commitguard.governance.exceptions import rule_severity
82
+ from commitguard.governance.inventory import governance_states
83
+ from commitguard.governance.settings import load_settings
84
+ from commitguard.policies.governance import EffectivePolicy, RepositoryMode
85
+ from commitguard.services.audit import AuditService
86
+
87
+ Posture = Literal["secure", "at_risk", "unprotected", "unknown"]
88
+ Drift = Literal["compliant", "customized", "drift", "unknown"]
89
+ SNAPSHOT_MAX_AGE = timedelta(minutes=5)
90
+ MATRIX_SORTS = ("name", "posture", "violations", "last_scan")
91
+ POSTURE_ORDER = {"at_risk": 0, "unprotected": 1, "unknown": 2, "secure": 3}
92
+ REPORT_KINDS = (
93
+ "compliance",
94
+ "violations",
95
+ "coverage",
96
+ "exceptions",
97
+ "policy_changes",
98
+ "installations",
99
+ )
100
+ #: A synchronisation still running after this long is reported as failed (crashed).
101
+ SYNC_STALLED_AFTER = timedelta(minutes=15)
102
+ #: Rows per report; a larger result is cut and the summary says ``truncated``.
103
+ REPORT_ROW_LIMIT = 10_000
104
+ NOT_A_CERTIFICATION = (
105
+ "Policy compliance report generated by CommitGuard. It describes CommitGuard policy "
106
+ "enforcement at the time shown; it is not a SOC 2, ISO 27001 or any other certification "
107
+ "or attestation."
108
+ )
109
+
110
+
111
+ class _View(BaseModel):
112
+ model_config = ConfigDict(frozen=True, extra="forbid")
113
+
114
+
115
+ class DriftDifference(_View):
116
+ policy_id: str
117
+ requested: str
118
+ requested_by: str
119
+ required: str
120
+ required_by: str
121
+ effective: str
122
+
123
+
124
+ class RepositoryPosture(_View):
125
+ repository_id: int
126
+ full_name: str
127
+ github_url: str
128
+ installation_id: int
129
+ groups: tuple[dict[str, str], ...]
130
+ connection: str
131
+ archived: bool
132
+ onboarding: str
133
+ mode: RepositoryMode
134
+ protection: ProtectionStatus
135
+ protection_reason: str
136
+ posture: Posture
137
+ posture_reasons: tuple[str, ...]
138
+ organization_policy_version: int | None
139
+ policy_state: str
140
+ last_scan_result: str | None
141
+ last_scan_at: datetime | None
142
+ open_violations: int
143
+ open_warnings: int
144
+ critical_open: int
145
+ active_exceptions: int
146
+ expiring_exceptions: int
147
+ drift: Drift
148
+ drift_differences: tuple[DriftDifference, ...]
149
+
150
+
151
+ class MatrixPage(_View):
152
+ items: tuple[RepositoryPosture, ...]
153
+ total: int
154
+ next_cursor: str | None
155
+ computed_at: datetime
156
+
157
+
158
+ class InstallationHealth(_View):
159
+ installation_id: int
160
+ account_login: str
161
+ state: str # active | suspended | deleted
162
+ sync: Literal["healthy", "syncing", "degraded", "failed", "never"]
163
+ sync_detail: str
164
+ last_success_at: datetime | None
165
+ repositories: int
166
+
167
+
168
+ class OrganizationPostureView(_View):
169
+ organization_id: int
170
+ login: str
171
+ type: str
172
+ github_url: str
173
+ posture: Posture
174
+ posture_reasons: tuple[str, ...]
175
+ members: int
176
+ repositories: int
177
+ required_repositories: int
178
+ compliant_repositories: int
179
+ compliance: str # "94 of 100 required repositories satisfy all mandatory controls"
180
+ by_posture: dict[str, int]
181
+ by_protection: dict[str, int]
182
+ monitor_mode: int
183
+ critical_open: int
184
+ high_open: int
185
+ active_exceptions: int
186
+ expiring_exceptions: int
187
+ expired_exceptions_30d: int
188
+ drift: dict[str, int]
189
+ installations: tuple[InstallationHealth, ...]
190
+ policy: dict[str, Any]
191
+ recent_activity: tuple[dict[str, Any], ...]
192
+ computed_at: datetime
193
+
194
+
195
+ class TrendPoint(_View):
196
+ day: str
197
+ values: dict[str, int]
198
+
199
+
200
+ class TrendsView(_View):
201
+ organization_id: int
202
+ days: int
203
+ history: tuple[TrendPoint, ...] # from scans and violations
204
+ snapshots: tuple[TrendPoint, ...] # from daily metric snapshots
205
+ snapshot_note: str
206
+ computed_at: datetime
207
+
208
+
209
+ class SearchResult(_View):
210
+ kind: str
211
+ id: str
212
+ title: str
213
+ detail: str
214
+ link: str
215
+
216
+
217
+ def repository_posture(
218
+ *,
219
+ summary: RepositorySummary,
220
+ mode: RepositoryMode,
221
+ policy_state: str,
222
+ high_exceptions: int,
223
+ ) -> tuple[Posture, tuple[str, ...]]:
224
+ """The explicit posture rules (module docstring). Returns the posture and why."""
225
+ if summary.app_connection is not AppConnection.CONNECTED:
226
+ return "at_risk", (summary.protection_reason,)
227
+ if not summary.monitoring_enabled:
228
+ return "unprotected", ("CommitGuard monitoring is paused.",)
229
+ if summary.protection is ProtectionStatus.UNPROTECTED:
230
+ return "unprotected", (summary.protection_reason,)
231
+ reasons: list[str] = []
232
+ if policy_state == "error":
233
+ reasons.append("The effective policy could not be resolved.")
234
+ if summary.protection is ProtectionStatus.CONFIGURATION_ERROR:
235
+ reasons.append(summary.protection_reason)
236
+ if summary.critical_open:
237
+ reasons.append(f"{summary.critical_open} open critical violation(s).")
238
+ if mode is RepositoryMode.MONITOR:
239
+ reasons.append("Monitor mode: violations are reported but not blocked.")
240
+ if high_exceptions:
241
+ reasons.append(f"{high_exceptions} active exception(s) lower high or critical rules.")
242
+ if reasons:
243
+ return "at_risk", tuple(reasons)
244
+ if summary.protection is ProtectionStatus.UNKNOWN:
245
+ return "unknown", (summary.protection_reason,)
246
+ return "secure", ("Protected, enforcing, no open critical violations.",)
247
+
248
+
249
+ def drift_from(effective: EffectivePolicy | None) -> tuple[Drift, tuple[DriftDifference, ...]]:
250
+ if effective is None:
251
+ return "unknown", ()
252
+ conflicts = tuple(
253
+ DriftDifference(
254
+ policy_id=c.policy_id,
255
+ requested="disabled" if not c.requested_enabled else c.requested_action.value,
256
+ requested_by=c.requested_label,
257
+ required=c.required_action.value,
258
+ required_by=c.required_label,
259
+ effective=c.effective_action.value,
260
+ )
261
+ for c in effective.conflicts
262
+ )
263
+ if conflicts:
264
+ return "drift", conflicts
265
+ customized = any(r.source.value == "repository_configuration" for r in effective.rules)
266
+ return ("customized" if customized else "compliant"), ()
267
+
268
+
269
+ class SecurityPostureService:
270
+ def __init__(
271
+ self,
272
+ store: SqliteStateStore,
273
+ audit: AuditService,
274
+ queries: DashboardQueries,
275
+ *,
276
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
277
+ ) -> None:
278
+ self._store = store
279
+ self._audit = audit
280
+ self._queries = queries
281
+ self._now = now
282
+
283
+ # -- data ------------------------------------------------------------- #
284
+ def _postures(self, principal: Principal, account_id: int) -> list[RepositoryPosture]:
285
+ """Posture of every repository of the organization the caller can see."""
286
+ scope = principal.scope(Permission.REPOSITORIES_READ, account_id=account_id)
287
+ rows = self._queries.repository_summaries(scope, organization_id=account_id)
288
+ # One row per repository ID: prefer the connected installation's row.
289
+ chosen: dict[int, tuple[sqlite3.Row, RepositorySummary]] = {}
290
+ for row, summary in rows:
291
+ current = chosen.get(summary.id)
292
+ if current is None or (
293
+ current[1].app_connection is not AppConnection.CONNECTED
294
+ and summary.app_connection is AppConnection.CONNECTED
295
+ ):
296
+ chosen[summary.id] = (row, summary)
297
+ states = governance_states(self._store, account_id)
298
+ default_mode = load_settings(self._store, account_id).settings.default_onboarding_mode
299
+ extras = self._repository_extras(account_id, [s.id for _, s in chosen.values()])
300
+ result = []
301
+ for _row, summary in chosen.values():
302
+ extra = extras.get(summary.id, {})
303
+ state = states.get(summary.id)
304
+ mode = state.mode if state else default_mode
305
+ policy_state = str(extra.get("policy_state", "pending"))
306
+ posture, reasons = repository_posture(
307
+ summary=summary,
308
+ mode=mode,
309
+ policy_state=policy_state,
310
+ high_exceptions=int(extra.get("high_exceptions", 0)),
311
+ )
312
+ drift, differences = drift_from(extra.get("effective"))
313
+ result.append(
314
+ RepositoryPosture(
315
+ repository_id=summary.id,
316
+ full_name=summary.full_name,
317
+ github_url=summary.github_url,
318
+ installation_id=summary.installation_id,
319
+ groups=tuple(extra.get("groups", ())),
320
+ connection=summary.app_connection.value,
321
+ archived=bool(extra.get("archived", False)),
322
+ onboarding=state.onboarding if state else "discovered",
323
+ mode=mode,
324
+ protection=summary.protection,
325
+ protection_reason=summary.protection_reason,
326
+ posture=posture,
327
+ posture_reasons=reasons,
328
+ organization_policy_version=extra.get("organization_policy_version"),
329
+ policy_state=policy_state,
330
+ last_scan_result=summary.last_scan.result.value if summary.last_scan else None,
331
+ last_scan_at=summary.last_scan.created_at if summary.last_scan else None,
332
+ open_violations=summary.open_violations,
333
+ open_warnings=summary.open_warnings,
334
+ critical_open=summary.critical_open,
335
+ active_exceptions=int(extra.get("active_exceptions", 0)),
336
+ expiring_exceptions=int(extra.get("expiring_exceptions", 0)),
337
+ drift=drift,
338
+ drift_differences=differences,
339
+ )
340
+ )
341
+ return result
342
+
343
+ def _repository_extras(
344
+ self, account_id: int, repository_ids: Sequence[int]
345
+ ) -> dict[int, dict[str, Any]]:
346
+ """Groups, exceptions, propagation and last-scan provenance, in a few queries."""
347
+ now = self._now()
348
+ extras: dict[int, dict[str, Any]] = {i: {"groups": []} for i in repository_ids}
349
+ ids_json = json.dumps(list(repository_ids))
350
+ group_members: dict[str, list[int]] = {}
351
+ for row in self._store.query(
352
+ "SELECT m.repository_id, g.group_id, g.name FROM repository_group_members m JOIN "
353
+ "repository_groups g ON g.group_id = m.group_id WHERE m.account_id = ? "
354
+ "AND g.archived_at IS NULL ORDER BY g.name_key",
355
+ (account_id,),
356
+ ):
357
+ repository_id = int(row["repository_id"])
358
+ group_members.setdefault(str(row["group_id"]), []).append(repository_id)
359
+ if repository_id in extras:
360
+ extras[repository_id]["groups"].append(
361
+ {"id": str(row["group_id"]), "name": str(row["name"])}
362
+ )
363
+ for row in self._store.query(
364
+ "SELECT rule_id, scope_type, scope_id, expires_at, permanent FROM policy_exceptions "
365
+ "WHERE account_id = ? AND status = 'active' AND (permanent = 1 OR expires_at > ?)",
366
+ (account_id, ts(now)),
367
+ ):
368
+ if row["scope_type"] == "organization":
369
+ targets: Sequence[int] = repository_ids
370
+ elif row["scope_type"] == "group":
371
+ targets = group_members.get(str(row["scope_id"]), [])
372
+ else:
373
+ targets = [int(row["scope_id"])] if str(row["scope_id"]).isdigit() else []
374
+ high = rule_severity(str(row["rule_id"])).rank >= Severity.HIGH.rank
375
+ expiring = (
376
+ not row["permanent"]
377
+ and row["expires_at"] is not None
378
+ and float(row["expires_at"]) - ts(now) <= timedelta(days=7).total_seconds()
379
+ )
380
+ for repository_id in targets:
381
+ if repository_id not in extras:
382
+ continue
383
+ entry = extras[repository_id]
384
+ entry["active_exceptions"] = entry.get("active_exceptions", 0) + 1
385
+ if high:
386
+ entry["high_exceptions"] = entry.get("high_exceptions", 0) + 1
387
+ if expiring:
388
+ entry["expiring_exceptions"] = entry.get("expiring_exceptions", 0) + 1
389
+ for row in self._store.query(
390
+ "SELECT repository_id, state, valid_until FROM repository_effective_policies "
391
+ "WHERE account_id = ? AND repository_id IN (SELECT value FROM json_each(?))",
392
+ (account_id, ids_json),
393
+ ):
394
+ state = str(row["state"])
395
+ if (
396
+ state == "up_to_date"
397
+ and row["valid_until"]
398
+ and float(row["valid_until"]) <= ts(now)
399
+ ):
400
+ state = "stale"
401
+ extras[int(row["repository_id"])]["policy_state"] = state
402
+ for row in self._store.query(
403
+ "SELECT k.repository_id, MAX(k.archived) AS archived FROM known_repositories k "
404
+ "JOIN installations i ON i.installation_id = k.installation_id WHERE "
405
+ "i.account_id = ? GROUP BY k.repository_id",
406
+ (account_id,),
407
+ ):
408
+ if int(row["repository_id"]) in extras:
409
+ extras[int(row["repository_id"])]["archived"] = bool(row["archived"])
410
+ # The organization policy version of each repository's latest completed scan
411
+ # (also scans recorded before Phase 8, which carry no governance record).
412
+ for row in self._store.query(
413
+ "SELECT j.repository_id, j.organization_policy_version FROM scan_jobs j JOIN "
414
+ "installations i ON i.installation_id = j.installation_id WHERE i.account_id = ? "
415
+ "AND j.completed_at IS NOT NULL AND j.sequence = (SELECT MAX(n.sequence) FROM "
416
+ "scan_jobs n WHERE n.installation_id = j.installation_id AND n.repository_id = "
417
+ "j.repository_id AND n.completed_at IS NOT NULL)",
418
+ (account_id,),
419
+ ):
420
+ if int(row["repository_id"]) in extras:
421
+ extras[int(row["repository_id"])]["organization_policy_version"] = row[
422
+ "organization_policy_version"
423
+ ]
424
+ for row in self._store.query(
425
+ "SELECT j.repository_id, j.governance FROM scan_jobs j "
426
+ "JOIN installations i ON i.installation_id = j.installation_id WHERE "
427
+ "i.account_id = ? AND j.governance IS NOT NULL AND j.sequence = (SELECT "
428
+ "MAX(n.sequence) "
429
+ "FROM scan_jobs n WHERE n.installation_id = j.installation_id AND n.repository_id = "
430
+ "j.repository_id AND n.governance IS NOT NULL)",
431
+ (account_id,),
432
+ ):
433
+ repository_id = int(row["repository_id"])
434
+ if repository_id not in extras:
435
+ continue
436
+ try:
437
+ record = json.loads(str(row["governance"]))
438
+ effective = record.get("effective")
439
+ extras[repository_id]["effective"] = (
440
+ EffectivePolicy.model_validate(effective) if effective else None
441
+ )
442
+ except (ValueError, TypeError):
443
+ continue
444
+ return extras
445
+
446
+ # -- organization ----------------------------------------------------- #
447
+ def _organization_ref(self, principal: Principal, account_id: int) -> tuple[str, str]:
448
+ membership = principal.memberships.get(account_id)
449
+ if membership is None:
450
+ raise NotFoundError()
451
+ return membership.account_login, membership.account_type
452
+
453
+ def installations(self, account_id: int) -> tuple[InstallationHealth, ...]:
454
+ now = self._now()
455
+ rows = self._store.query(
456
+ "SELECT i.installation_id, i.account_login, i.state, s.state AS sync_state, "
457
+ "s.started_at, s.last_success_at, s.error, s.repositories, (SELECT COUNT(*) FROM "
458
+ "installation_repositories r WHERE r.installation_id = i.installation_id) AS listed "
459
+ "FROM installations i LEFT JOIN installation_sync_status s ON s.installation_id = "
460
+ "i.installation_id WHERE i.account_id = ? AND i.state != 'deleted' "
461
+ "ORDER BY i.installation_id",
462
+ (account_id,),
463
+ )
464
+ result = []
465
+ for row in rows:
466
+ last_success = dt(row["last_success_at"])
467
+ sync: Literal["healthy", "syncing", "degraded", "failed", "never"]
468
+ if row["sync_state"] == "failed":
469
+ sync, detail = "failed", str(row["error"] or "The last synchronisation failed.")
470
+ elif row["sync_state"] == "syncing" and (
471
+ row["started_at"] is None or now - req_dt(row["started_at"]) > SYNC_STALLED_AFTER
472
+ ):
473
+ sync, detail = "failed", "The last synchronisation did not finish."
474
+ elif row["sync_state"] == "syncing":
475
+ sync, detail = "syncing", "Synchronising repositories with GitHub."
476
+ elif row["state"] != "active":
477
+ sync, detail = "degraded", f"The installation is {row['state']}."
478
+ elif last_success is None:
479
+ sync, detail = (
480
+ "never",
481
+ "Repositories are known from GitHub events; no full synchronisation yet.",
482
+ )
483
+ elif now - last_success > timedelta(days=7):
484
+ sync, detail = "degraded", "Not synchronised with GitHub in the last 7 days."
485
+ else:
486
+ sync, detail = "healthy", "Synchronised with GitHub."
487
+ result.append(
488
+ InstallationHealth(
489
+ installation_id=int(row["installation_id"]),
490
+ account_login=str(row["account_login"]),
491
+ state=str(row["state"]),
492
+ sync=sync,
493
+ sync_detail=detail,
494
+ last_success_at=last_success,
495
+ repositories=int(row["listed"]),
496
+ )
497
+ )
498
+ return tuple(result)
499
+
500
+ def overview(self, principal: Principal, account_id: int) -> OrganizationPostureView:
501
+ require(principal, Permission.SECURITY_READ, account_id)
502
+ login, account_type = self._organization_ref(principal, account_id)
503
+ now = self._now()
504
+ postures = self._postures(principal, account_id)
505
+ states = governance_states(self._store, account_id)
506
+ installations = self.installations(account_id)
507
+ required = [
508
+ p
509
+ for p in postures
510
+ if p.connection == "connected"
511
+ and not p.archived
512
+ and (
513
+ states[p.repository_id].onboarding == "onboarded"
514
+ if p.repository_id in states
515
+ else True
516
+ )
517
+ ]
518
+ compliant = sum(1 for p in required if p.posture == "secure")
519
+ by_posture = {k: sum(1 for p in postures if p.posture == k) for k in POSTURE_ORDER}
520
+ by_protection = {
521
+ status.value: sum(1 for p in postures if p.protection is status)
522
+ for status in ProtectionStatus
523
+ }
524
+ reasons: list[str] = []
525
+ unhealthy = [i for i in installations if i.state != "active" or i.sync == "failed"]
526
+ if not postures:
527
+ posture: Posture = "unknown"
528
+ reasons.append("No repositories are connected to CommitGuard yet.")
529
+ elif (
530
+ unhealthy
531
+ or by_posture["at_risk"]
532
+ or (by_posture["unprotected"] and by_posture["unprotected"] < len(postures))
533
+ ):
534
+ posture = "at_risk"
535
+ for installation in unhealthy:
536
+ reasons.append(
537
+ f"Installation {installation.account_login} ({installation.installation_id}): "
538
+ f"{installation.sync_detail}"
539
+ )
540
+ if by_posture["at_risk"]:
541
+ reasons.append(f"{by_posture['at_risk']} repository(ies) at risk.")
542
+ if by_posture["unprotected"]:
543
+ reasons.append(f"{by_posture['unprotected']} repository(ies) unprotected.")
544
+ elif by_posture["unprotected"] == len(postures):
545
+ posture = "unprotected"
546
+ reasons.append(
547
+ "No repository is protected: CommitGuard checks do not block merges anywhere."
548
+ )
549
+ elif by_posture["unknown"]:
550
+ posture = "unknown"
551
+ reasons.append(
552
+ f"Branch protection of {by_posture['unknown']} repository(ies) is not verified."
553
+ )
554
+ else:
555
+ posture = "secure"
556
+ reasons.append("Every repository is protected and enforcing; installations healthy.")
557
+ members = int(
558
+ self._store.query(
559
+ "SELECT COUNT(*) AS n FROM memberships WHERE account_id = ?", (account_id,)
560
+ )[0]["n"]
561
+ )
562
+ exception_counts = self._store.query(
563
+ "SELECT SUM(status = 'active' AND (permanent = 1 OR expires_at > ?)) AS active, "
564
+ "SUM(status = 'active' AND permanent = 0 AND expires_at > ? AND expires_at <= ?) "
565
+ "AS expiring, SUM(status = 'expired' AND expired_at >= ?) AS expired, "
566
+ "SUM(status = 'requested') AS requested FROM policy_exceptions WHERE account_id = ?",
567
+ (
568
+ ts(now),
569
+ ts(now),
570
+ ts(now + timedelta(days=7)),
571
+ ts(now - timedelta(days=30)),
572
+ account_id,
573
+ ),
574
+ )[0]
575
+ return OrganizationPostureView(
576
+ organization_id=account_id,
577
+ login=login,
578
+ type=account_type,
579
+ github_url=f"https://github.com/{login}",
580
+ posture=posture,
581
+ posture_reasons=tuple(reasons),
582
+ members=members,
583
+ repositories=len(postures),
584
+ required_repositories=len(required),
585
+ compliant_repositories=compliant,
586
+ compliance=(
587
+ f"{compliant} of {len(required)} required repositories satisfy all mandatory "
588
+ "controls"
589
+ ),
590
+ by_posture=by_posture,
591
+ by_protection=by_protection,
592
+ monitor_mode=sum(1 for p in postures if p.mode is RepositoryMode.MONITOR),
593
+ critical_open=sum(p.critical_open for p in postures),
594
+ high_open=self._high_open(account_id, [p.repository_id for p in postures]),
595
+ active_exceptions=int(exception_counts["active"] or 0),
596
+ expiring_exceptions=int(exception_counts["expiring"] or 0),
597
+ expired_exceptions_30d=int(exception_counts["expired"] or 0),
598
+ drift={
599
+ k: sum(1 for p in postures if p.drift == k)
600
+ for k in ("compliant", "customized", "drift", "unknown")
601
+ },
602
+ installations=installations,
603
+ policy=self._policy_status(account_id, int(exception_counts["requested"] or 0)),
604
+ recent_activity=self._recent_activity(principal, account_id),
605
+ computed_at=now,
606
+ )
607
+
608
+ def _high_open(self, account_id: int, repository_ids: Sequence[int]) -> int:
609
+ """Open high violations of the repositories the caller can see."""
610
+ return int(
611
+ self._store.query(
612
+ "SELECT COUNT(*) AS n FROM violations v JOIN installations i ON "
613
+ "i.installation_id = v.installation_id WHERE i.account_id = ? "
614
+ "AND v.status = 'open' AND v.severity = 'high' "
615
+ "AND v.repository_id IN (SELECT value FROM json_each(?))",
616
+ (account_id, json.dumps(sorted(set(repository_ids)))),
617
+ )[0]["n"]
618
+ )
619
+
620
+ def _policy_status(self, account_id: int, requested_exceptions: int) -> dict[str, Any]:
621
+ latest = self._store.query(
622
+ "SELECT version, created_at, created_by_login FROM organization_policy_versions "
623
+ "WHERE account_id = ? ORDER BY version DESC LIMIT 1",
624
+ (account_id,),
625
+ )
626
+ pending = self._store.query(
627
+ "SELECT COUNT(*) AS n FROM policy_drafts WHERE account_id = ? "
628
+ "AND state = 'pending_approval'",
629
+ (account_id,),
630
+ )[0]["n"]
631
+ rollouts = self._store.query(
632
+ "SELECT COUNT(*) AS n FROM policy_rollouts WHERE account_id = ? "
633
+ "AND state IN ('pilot', 'rollout', 'paused')",
634
+ (account_id,),
635
+ )[0]["n"]
636
+ propagation = {
637
+ str(r["state"]): int(r["n"])
638
+ for r in self._store.query(
639
+ "SELECT state, COUNT(*) AS n FROM repository_effective_policies "
640
+ "WHERE account_id = ? GROUP BY state",
641
+ (account_id,),
642
+ )
643
+ }
644
+ baseline = load_settings(self._store, account_id).settings.security_baseline
645
+ return {
646
+ "organization_version": int(latest[0]["version"]) if latest else 0,
647
+ "updated_at": req_dt(latest[0]["created_at"]).isoformat() if latest else None,
648
+ "updated_by": latest[0]["created_by_login"] if latest else None,
649
+ "approvals_pending": int(pending),
650
+ "exceptions_requested": requested_exceptions,
651
+ "rollouts_in_progress": int(rollouts),
652
+ "propagation": propagation,
653
+ "baseline": {k: v.value for k, v in baseline.items()},
654
+ }
655
+
656
+ def _recent_activity(self, principal: Principal, account_id: int) -> tuple[dict[str, Any], ...]:
657
+ if not principal.can(Permission.AUDIT_READ, account_id):
658
+ return ()
659
+ types = (
660
+ "organization_policy_changed",
661
+ "organization_policy_rolled_back",
662
+ "policy_published",
663
+ "policy_emergency_published",
664
+ "exception_approved",
665
+ "exception_revoked",
666
+ "exception_expired",
667
+ "installation_suspended",
668
+ "installation_removed",
669
+ "installation_unsuspended",
670
+ "policy_rollout_paused",
671
+ "organization_settings_changed",
672
+ "repository_mode_changed",
673
+ )
674
+ rows = self._store.query(
675
+ "SELECT event_id, occurred_at, type, actor_login, repository_id FROM audit_events "
676
+ "WHERE account_id = ? AND type IN (SELECT value FROM json_each(?)) "
677
+ "ORDER BY occurred_at DESC LIMIT 12",
678
+ (account_id, json.dumps(types)),
679
+ )
680
+ return tuple(
681
+ {
682
+ "id": str(r["event_id"]),
683
+ "type": str(r["type"]),
684
+ "occurred_at": req_dt(r["occurred_at"]).isoformat(),
685
+ "actor": r["actor_login"],
686
+ }
687
+ for r in rows
688
+ )
689
+
690
+ # -- matrix ----------------------------------------------------------- #
691
+ def matrix(
692
+ self,
693
+ principal: Principal,
694
+ account_id: int,
695
+ *,
696
+ filters: Mapping[str, str | None],
697
+ offset: int,
698
+ limit: int,
699
+ ) -> MatrixPage:
700
+ require(principal, Permission.SECURITY_READ, account_id)
701
+ now = self._now()
702
+ items = self._postures(principal, account_id)
703
+ q = filters.get("q")
704
+ if q:
705
+ needle = q.casefold()
706
+ items = [p for p in items if needle in p.full_name.casefold()]
707
+ group = filters.get("group")
708
+ if group:
709
+ if not is_hex_id(group):
710
+ raise InputValidationError("group must be a group ID", field="group")
711
+ items = [p for p in items if any(g["id"] == group for g in p.groups)]
712
+ checks: dict[str, tuple[set[str], Callable[[RepositoryPosture], str]]] = {
713
+ "posture": ({"secure", "at_risk", "unprotected", "unknown"}, lambda p: p.posture),
714
+ "protection": ({s.value for s in ProtectionStatus}, lambda p: p.protection.value),
715
+ "mode": ({"monitor", "enforce"}, lambda p: p.mode.value),
716
+ "onboarding": ({"discovered", "onboarded", "excluded"}, lambda p: p.onboarding),
717
+ "policy_state": (
718
+ {"up_to_date", "stale", "syncing", "error", "pending"},
719
+ lambda p: p.policy_state,
720
+ ),
721
+ "drift": ({"compliant", "customized", "drift", "unknown"}, lambda p: p.drift),
722
+ }
723
+ for name, (allowed, getter) in checks.items():
724
+ value = filters.get(name)
725
+ if value:
726
+ if value not in allowed:
727
+ raise InputValidationError(f"unknown {name}", field=name)
728
+ items = [p for p in items if getter(p) == value]
729
+ exceptions = filters.get("exceptions")
730
+ if exceptions == "active":
731
+ items = [p for p in items if p.active_exceptions]
732
+ elif exceptions == "expiring":
733
+ items = [p for p in items if p.expiring_exceptions]
734
+ elif exceptions == "none":
735
+ items = [p for p in items if not p.active_exceptions]
736
+ elif exceptions:
737
+ raise InputValidationError(
738
+ "exceptions must be active, expiring or none", field="exceptions"
739
+ )
740
+ severity = filters.get("severity")
741
+ if severity == "critical":
742
+ items = [p for p in items if p.critical_open]
743
+ elif severity == "any":
744
+ items = [p for p in items if p.open_violations or p.open_warnings]
745
+ elif severity:
746
+ raise InputValidationError("severity must be critical or any", field="severity")
747
+ last_scan = filters.get("last_scan")
748
+ if last_scan == "never":
749
+ items = [p for p in items if p.last_scan_at is None]
750
+ elif last_scan and last_scan.isdigit() and 0 < int(last_scan) <= 365:
751
+ cutoff = now - timedelta(days=int(last_scan))
752
+ items = [p for p in items if p.last_scan_at is None or p.last_scan_at < cutoff]
753
+ elif last_scan:
754
+ raise InputValidationError(
755
+ "last_scan must be never or a number of days", field="last_scan"
756
+ )
757
+ sort = filters.get("sort") or "posture"
758
+ if sort not in MATRIX_SORTS:
759
+ raise InputValidationError("unknown sort", field="sort")
760
+ epoch = datetime.fromtimestamp(0, UTC)
761
+ keys: dict[str, Callable[[RepositoryPosture], Any]] = {
762
+ "name": lambda p: p.full_name.casefold(),
763
+ "posture": lambda p: (POSTURE_ORDER[p.posture], -p.critical_open, p.full_name),
764
+ "violations": lambda p: (-p.critical_open, -p.open_violations, p.full_name),
765
+ "last_scan": lambda p: (p.last_scan_at or epoch, p.full_name),
766
+ }
767
+ items.sort(key=keys[sort])
768
+ page = items[offset : offset + limit]
769
+ from commitguard.controlplane.pagination import encode_cursor
770
+
771
+ return MatrixPage(
772
+ items=tuple(page),
773
+ total=len(items),
774
+ next_cursor=encode_cursor([offset + limit]) if len(items) > offset + limit else None,
775
+ computed_at=now,
776
+ )
777
+
778
+ # -- trends and snapshots --------------------------------------------- #
779
+ def snapshot_metrics(self) -> int:
780
+ """Write today's metrics snapshot per organization (background job)."""
781
+ now = self._now()
782
+ day = now.date().isoformat()
783
+ written = 0
784
+ for row in self._store.query(
785
+ "SELECT DISTINCT account_id FROM installations WHERE state != 'deleted'"
786
+ ):
787
+ account_id = int(row["account_id"])
788
+ existing = self._store.query(
789
+ "SELECT computed_at FROM security_metric_snapshots WHERE account_id = ? AND day = "
790
+ "?",
791
+ (account_id, day),
792
+ )
793
+ if existing and now - req_dt(existing[0]["computed_at"]) < timedelta(hours=1):
794
+ continue
795
+ document = self._system_metrics(account_id)
796
+ with self._store.transaction() as db:
797
+ db.execute(
798
+ "INSERT INTO security_metric_snapshots (account_id, day, computed_at, "
799
+ "document) "
800
+ "VALUES (?, ?, ?, ?) ON CONFLICT (account_id, day) DO UPDATE SET "
801
+ "computed_at = excluded.computed_at, document = excluded.document",
802
+ (account_id, day, ts(now), json.dumps(document, sort_keys=True)),
803
+ )
804
+ written += 1
805
+ return written
806
+
807
+ def _system_metrics(self, account_id: int) -> dict[str, int]:
808
+ """Counts that do not depend on a session's visibility (organization totals)."""
809
+ now = self._now()
810
+ protection = {
811
+ str(r["branch_protection"]): int(r["n"])
812
+ for r in self._store.query(
813
+ "SELECT e.branch_protection, COUNT(*) AS n FROM enforcement_status e JOIN "
814
+ "installations i ON i.installation_id = e.installation_id WHERE i.account_id = ? "
815
+ "AND i.state = 'active' GROUP BY e.branch_protection",
816
+ (account_id,),
817
+ )
818
+ }
819
+ violations = self._store.query(
820
+ "SELECT SUM(v.severity = 'critical') AS critical, COUNT(*) AS open FROM violations v "
821
+ "JOIN installations i ON i.installation_id = v.installation_id WHERE i.account_id = ? "
822
+ "AND v.status = 'open'",
823
+ (account_id,),
824
+ )[0]
825
+ exceptions = self._store.query(
826
+ "SELECT COUNT(*) AS n FROM policy_exceptions WHERE account_id = ? AND status = "
827
+ "'active' "
828
+ "AND (permanent = 1 OR expires_at > ?)",
829
+ (account_id, ts(now)),
830
+ )[0]["n"]
831
+ drift = 0
832
+ for row in self._store.query(
833
+ "SELECT j.governance FROM scan_jobs j JOIN installations i ON i.installation_id = "
834
+ "j.installation_id WHERE i.account_id = ? AND j.governance IS NOT NULL AND "
835
+ "j.sequence = (SELECT MAX(n.sequence) FROM scan_jobs n WHERE n.installation_id = "
836
+ "j.installation_id AND n.repository_id = j.repository_id AND n.governance IS NOT NULL)",
837
+ (account_id,),
838
+ ):
839
+ try:
840
+ effective = json.loads(str(row["governance"])).get("effective") or {}
841
+ except ValueError:
842
+ continue
843
+ if any(r.get("conflict") for r in effective.get("rules", [])):
844
+ drift += 1
845
+ return {
846
+ "protected_repositories": protection.get("required", 0),
847
+ "unprotected_repositories": protection.get("not_required", 0),
848
+ "open_violations": int(violations["open"] or 0),
849
+ "critical_open": int(violations["critical"] or 0),
850
+ "active_exceptions": int(exceptions),
851
+ "drift_repositories": drift,
852
+ }
853
+
854
+ def trends(self, principal: Principal, account_id: int, *, days: int) -> TrendsView:
855
+ require(principal, Permission.SECURITY_READ, account_id)
856
+ if not 1 <= days <= 365:
857
+ raise InputValidationError("days must be between 1 and 365", field="days")
858
+ now = self._now()
859
+ start = (now - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
860
+ history: dict[str, dict[str, int]] = {}
861
+ for row in self._store.query(
862
+ "SELECT date(j.completed_at, 'unixepoch') AS day, COUNT(*) AS scans, "
863
+ "SUM(j.state = 'failed') AS blocked, SUM(j.state = 'error') AS errors FROM scan_jobs j "
864
+ "JOIN installations i ON i.installation_id = j.installation_id WHERE i.account_id = ? "
865
+ "AND j.completed_at >= ? GROUP BY day",
866
+ (account_id, ts(start)),
867
+ ):
868
+ history.setdefault(str(row["day"]), {}).update(
869
+ scans=int(row["scans"]),
870
+ blocked_scans=int(row["blocked"] or 0),
871
+ scan_errors=int(row["errors"] or 0),
872
+ )
873
+ for row in self._store.query(
874
+ "SELECT date(v.first_detected_at, 'unixepoch') AS day, COUNT(*) AS violations, "
875
+ "SUM(v.severity = 'critical') AS critical FROM violations v JOIN installations i ON "
876
+ "i.installation_id = v.installation_id WHERE i.account_id = ? "
877
+ "AND v.first_detected_at >= ? GROUP BY day",
878
+ (account_id, ts(start)),
879
+ ):
880
+ history.setdefault(str(row["day"]), {}).update(
881
+ new_violations=int(row["violations"]),
882
+ new_critical=int(row["critical"] or 0),
883
+ )
884
+ snapshots = [
885
+ TrendPoint(day=str(r["day"]), values=json.loads(str(r["document"])))
886
+ for r in self._store.query(
887
+ "SELECT day, document FROM security_metric_snapshots WHERE account_id = ? "
888
+ "AND day >= ? ORDER BY day",
889
+ (account_id, start.date().isoformat()),
890
+ )
891
+ ]
892
+ return TrendsView(
893
+ organization_id=account_id,
894
+ days=days,
895
+ history=tuple(
896
+ TrendPoint(day=day, values=values) for day, values in sorted(history.items())
897
+ ),
898
+ snapshots=tuple(snapshots),
899
+ snapshot_note=(
900
+ "Protection, exceptions and drift over time come from daily snapshots taken by "
901
+ "CommitGuard; days before the first snapshot have no data."
902
+ ),
903
+ computed_at=now,
904
+ )
905
+
906
+ # -- reports ---------------------------------------------------------- #
907
+ def report(
908
+ self, principal: Principal, account_id: int, *, kind: str, fmt: str
909
+ ) -> tuple[str, bytes, str]:
910
+ """(content type, body, filename). A point-in-time snapshot, audited."""
911
+ require(principal, Permission.SECURITY_READ, account_id)
912
+ if kind not in REPORT_KINDS:
913
+ raise InputValidationError(
914
+ f"report must be one of {', '.join(REPORT_KINDS)}", field="report"
915
+ )
916
+ if fmt not in ("json", "csv"):
917
+ raise InputValidationError("format must be json or csv", field="format")
918
+ if kind in ("policy_changes",) and not principal.can(Permission.AUDIT_READ, account_id):
919
+ raise NotFoundError()
920
+ now = self._now()
921
+ login, _ = self._organization_ref(principal, account_id)
922
+ rows, summary = self._report_rows(principal, account_id, kind)
923
+ document = {
924
+ "report": kind,
925
+ "organization": login,
926
+ "organization_id": account_id,
927
+ "generated_at": now.isoformat(),
928
+ "generated_by": principal.login,
929
+ "notice": NOT_A_CERTIFICATION,
930
+ "summary": summary,
931
+ "rows": rows,
932
+ }
933
+ with self._store.transaction() as db:
934
+ stored = self._store.insert_audit_event(
935
+ db,
936
+ self._audit.build(
937
+ AuditEventType.REPORT_EXPORTED,
938
+ actor=Actor.user(principal.user_id, principal.login),
939
+ account_id=account_id,
940
+ report=kind,
941
+ format=fmt,
942
+ rows=len(rows),
943
+ ),
944
+ )
945
+ self._audit.log_stored(stored)
946
+ stamp = now.strftime("%Y%m%dT%H%M%SZ")
947
+ filename = f"commitguard-{login}-{kind}-{stamp}.{fmt}"
948
+ if fmt == "json":
949
+ body = json.dumps(document, indent=2, sort_keys=True, default=str).encode("utf-8")
950
+ return "application/json", body, filename
951
+ buffer = io.StringIO()
952
+ writer = csv.writer(buffer)
953
+ writer.writerow([f"# {NOT_A_CERTIFICATION}"])
954
+ writer.writerow([f"# organization={login} generated_at={now.isoformat()} report={kind}"])
955
+ columns = sorted({key for row in rows for key in row}) if rows else ["empty"]
956
+ writer.writerow(columns)
957
+ for row in rows:
958
+ writer.writerow([_csv_cell(row.get(column)) for column in columns])
959
+ return "text/csv; charset=utf-8", buffer.getvalue().encode("utf-8"), filename
960
+
961
+ def _report_rows(
962
+ self, principal: Principal, account_id: int, kind: str
963
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
964
+ if kind in ("compliance", "coverage"):
965
+ overview = self.overview(principal, account_id)
966
+ postures = self._postures(principal, account_id)
967
+ rows = [
968
+ {
969
+ "repository": p.full_name,
970
+ "posture": p.posture,
971
+ "reasons": "; ".join(p.posture_reasons),
972
+ "protection": p.protection.value,
973
+ "connection": p.connection,
974
+ "mode": p.mode.value,
975
+ "onboarding": p.onboarding,
976
+ "policy_state": p.policy_state,
977
+ "organization_policy_version": p.organization_policy_version,
978
+ "open_violations": p.open_violations,
979
+ "critical_open": p.critical_open,
980
+ "active_exceptions": p.active_exceptions,
981
+ "drift": p.drift,
982
+ "last_scan_at": p.last_scan_at.isoformat() if p.last_scan_at else None,
983
+ }
984
+ for p in sorted(postures, key=lambda p: p.full_name.casefold())
985
+ ]
986
+ summary = {
987
+ "posture": overview.posture,
988
+ "repositories": overview.repositories,
989
+ "protected": overview.by_protection.get("protected", 0),
990
+ "at_risk": overview.by_posture.get("at_risk", 0),
991
+ "unprotected": overview.by_posture.get("unprotected", 0),
992
+ "critical_findings": overview.critical_open,
993
+ "active_exceptions": overview.active_exceptions,
994
+ "compliance": overview.compliance,
995
+ }
996
+ return rows, summary
997
+ if kind == "violations":
998
+ scope = principal.scope(Permission.VIOLATIONS_READ, account_id=account_id)
999
+ rows = [
1000
+ {
1001
+ "rule": r["rule_id"],
1002
+ "severity": r["severity"],
1003
+ "action": r["action"],
1004
+ "status": r["status"],
1005
+ "repository": f"{r['owner']}/{r['name']}",
1006
+ "open": int(r["n"]),
1007
+ }
1008
+ for r in self._store.query(
1009
+ "SELECT v.rule_id, v.severity, v.action, v.status, k.owner, k.name, COUNT(*) "
1010
+ "AS n FROM violations v JOIN known_repositories k ON k.installation_id = "
1011
+ "v.installation_id AND k.repository_id = v.repository_id WHERE "
1012
+ "v.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 "
1013
+ "FROM session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id "
1014
+ "= v.installation_id AND sr.repository_id = v.repository_id) GROUP BY "
1015
+ "v.rule_id, v.severity, v.action, v.status, k.owner, k.name LIMIT ?",
1016
+ (scope.installations_json, scope.session_hash, REPORT_ROW_LIMIT),
1017
+ )
1018
+ ]
1019
+ return rows, {"groups": len(rows), "truncated": len(rows) >= REPORT_ROW_LIMIT}
1020
+ if kind == "exceptions":
1021
+ rows = [
1022
+ {
1023
+ "id": r["exception_id"],
1024
+ "rule": r["rule_id"],
1025
+ "scope": r["scope_type"],
1026
+ "scope_id": r["scope_id"],
1027
+ "action": r["action"],
1028
+ "status": r["status"],
1029
+ "reason": r["reason"],
1030
+ "requested_by": r["requested_by_login"],
1031
+ "approved_by": r["decided_by_login"],
1032
+ "expires_at": req_dt(r["expires_at"]).isoformat() if r["expires_at"] else None,
1033
+ "permanent": bool(r["permanent"]),
1034
+ }
1035
+ for r in self._store.query(
1036
+ "SELECT * FROM policy_exceptions WHERE account_id = ? ORDER BY requested_at "
1037
+ "DESC LIMIT ?",
1038
+ (account_id, REPORT_ROW_LIMIT),
1039
+ )
1040
+ if r["scope_type"] != "repository"
1041
+ or int(r["scope_id"]) in self._visible(principal, account_id)
1042
+ ]
1043
+ return rows, {"exceptions": len(rows), "truncated": len(rows) >= REPORT_ROW_LIMIT}
1044
+ if kind == "policy_changes":
1045
+ rows = [
1046
+ {
1047
+ "occurred_at": req_dt(r["occurred_at"]).isoformat(),
1048
+ "type": r["type"],
1049
+ "actor": r["actor_login"],
1050
+ "details": json.loads(str(r["document"])).get("data", {}),
1051
+ }
1052
+ for r in self._store.query(
1053
+ "SELECT occurred_at, type, actor_login, document FROM audit_events WHERE "
1054
+ "account_id = ? AND type IN ('organization_policy_changed', "
1055
+ "'organization_policy_rolled_back', 'policy_published', 'policy_rolled_back', "
1056
+ "'policy_emergency_published', 'policy_approved', 'policy_rejected', "
1057
+ "'organization_settings_changed', 'organization_rules_changed') "
1058
+ "ORDER BY occurred_at DESC LIMIT ?",
1059
+ (account_id, REPORT_ROW_LIMIT),
1060
+ )
1061
+ ]
1062
+ return rows, {"changes": len(rows), "truncated": len(rows) >= REPORT_ROW_LIMIT}
1063
+ rows = [i.model_dump(mode="json") for i in self.installations(account_id)]
1064
+ return rows, {"installations": len(rows)}
1065
+
1066
+ def _visible(self, principal: Principal, account_id: int) -> set[int]:
1067
+ return visible_repository_ids(self._store, principal, account_id)
1068
+
1069
+ # -- search ----------------------------------------------------------- #
1070
+ def search(self, principal: Principal, account_id: int, q: str) -> list[SearchResult]:
1071
+ require(principal, Permission.ORGANIZATION_READ, account_id)
1072
+ needle = q.strip()
1073
+ if not 2 <= len(needle) <= 100:
1074
+ raise InputValidationError("q must be 2-100 characters", field="q")
1075
+ pattern = like_pattern(needle)
1076
+ results: list[SearchResult] = []
1077
+ visible = self._visible(principal, account_id)
1078
+ if principal.can(Permission.REPOSITORIES_READ, account_id):
1079
+ for r in self._store.query(
1080
+ "SELECT DISTINCT k.repository_id, k.owner, k.name FROM known_repositories k JOIN "
1081
+ "installations i ON i.installation_id = k.installation_id WHERE i.account_id = ? "
1082
+ "AND (k.owner || '/' || k.name) LIKE ? ESCAPE '\\' ORDER BY k.name LIMIT 30",
1083
+ (account_id, pattern),
1084
+ ):
1085
+ if int(r["repository_id"]) in visible and len(results) < 10:
1086
+ results.append(
1087
+ SearchResult(
1088
+ kind="repository",
1089
+ id=str(r["repository_id"]),
1090
+ title=f"{r['owner']}/{r['name']}",
1091
+ detail="Repository",
1092
+ link=f"/repositories/{r['repository_id']}",
1093
+ )
1094
+ )
1095
+ for r in self._store.query(
1096
+ "SELECT group_id, name, description FROM repository_groups WHERE account_id = ? "
1097
+ "AND archived_at IS NULL AND name LIKE ? ESCAPE '\\' LIMIT 10",
1098
+ (account_id, pattern),
1099
+ ):
1100
+ results.append(
1101
+ SearchResult(
1102
+ kind="group",
1103
+ id=str(r["group_id"]),
1104
+ title=str(r["name"]),
1105
+ detail=str(r["description"] or "Repository group"),
1106
+ link=f"/organization/groups/{r['group_id']}",
1107
+ )
1108
+ )
1109
+ if principal.can(Permission.POLICIES_READ, account_id):
1110
+ for r in self._store.query(
1111
+ "SELECT draft_id, title, state FROM policy_drafts WHERE account_id = ? "
1112
+ "AND title LIKE ? ESCAPE '\\' ORDER BY updated_at DESC LIMIT 10",
1113
+ (account_id, pattern),
1114
+ ):
1115
+ results.append(
1116
+ SearchResult(
1117
+ kind="policy",
1118
+ id=str(r["draft_id"]),
1119
+ title=str(r["title"]),
1120
+ detail=f"Policy draft ({r['state']})",
1121
+ link=f"/organization/policies/drafts/{r['draft_id']}",
1122
+ )
1123
+ )
1124
+ if principal.can(Permission.RULES_READ, account_id):
1125
+ lowered = needle.casefold()
1126
+ for entry in CATALOG:
1127
+ if lowered in entry.rule_id or lowered in entry.name.casefold():
1128
+ results.append(
1129
+ SearchResult(
1130
+ kind="rule",
1131
+ id=entry.rule_id,
1132
+ title=entry.name,
1133
+ detail=f"Rule {entry.rule_id} ({entry.severity.value})",
1134
+ link=f"/rules/{entry.rule_id}",
1135
+ )
1136
+ )
1137
+ if principal.can(Permission.EXCEPTIONS_READ, account_id):
1138
+ for r in self._store.query(
1139
+ "SELECT exception_id, rule_id, scope_type, scope_id, status, reason FROM "
1140
+ "policy_exceptions WHERE account_id = ? AND (rule_id LIKE ? ESCAPE '\\' OR reason "
1141
+ "LIKE ? ESCAPE '\\') ORDER BY requested_at DESC LIMIT 20",
1142
+ (account_id, pattern, pattern),
1143
+ ):
1144
+ if r["scope_type"] == "repository" and int(r["scope_id"]) not in visible:
1145
+ continue
1146
+ results.append(
1147
+ SearchResult(
1148
+ kind="exception",
1149
+ id=str(r["exception_id"]),
1150
+ title=f"Exception: {r['rule_id']} ({r['status']})",
1151
+ detail=str(r["reason"])[:120],
1152
+ link=f"/organization/exceptions/{r['exception_id']}",
1153
+ )
1154
+ )
1155
+ if principal.can(Permission.VIOLATIONS_READ, account_id):
1156
+ scope = principal.scope(Permission.VIOLATIONS_READ, account_id=account_id)
1157
+ for r in self._store.query(
1158
+ "SELECT v.violation_id, v.rule_id, v.title, v.commit_sha, v.status FROM violations "
1159
+ "v WHERE v.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT "
1160
+ "1 FROM session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = "
1161
+ "v.installation_id AND sr.repository_id = v.repository_id) AND (v.rule_id LIKE ? "
1162
+ "ESCAPE '\\' OR v.title LIKE ? ESCAPE '\\' OR v.commit_sha LIKE ?) "
1163
+ "ORDER BY v.last_detected_at DESC LIMIT 10",
1164
+ (
1165
+ scope.installations_json,
1166
+ scope.session_hash,
1167
+ pattern,
1168
+ pattern,
1169
+ f"{needle.lower()}%" if needle.isalnum() else "\x00",
1170
+ ),
1171
+ ):
1172
+ results.append(
1173
+ SearchResult(
1174
+ kind="finding",
1175
+ id=str(r["violation_id"]),
1176
+ title=str(r["title"]),
1177
+ detail=f"{r['rule_id']} · {r['status']} · {str(r['commit_sha'])[:12]}",
1178
+ link=f"/violations/{r['violation_id']}",
1179
+ )
1180
+ )
1181
+ return results[:50]
1182
+
1183
+ # -- acknowledgement -------------------------------------------------- #
1184
+ def acknowledge_event(
1185
+ self, principal: Principal, account_id: int, event_id: str, note: object
1186
+ ) -> dict[str, Any]:
1187
+ """Record that someone saw a critical security event. It does not resolve anything."""
1188
+ require(principal, Permission.VIOLATIONS_MANAGE, account_id)
1189
+ if not is_hex_id(event_id):
1190
+ raise NotFoundError()
1191
+ rows = self._store.query(
1192
+ "SELECT event_id, severity, type, title FROM notification_events WHERE event_id = ? "
1193
+ "AND account_id = ?",
1194
+ (event_id, account_id),
1195
+ )
1196
+ if not rows:
1197
+ raise NotFoundError()
1198
+ if rows[0]["severity"] not in ("critical", "high"):
1199
+ raise ConflictError("Only critical and high security events are acknowledged.")
1200
+ note_text = text(note, "note", limit=500)
1201
+ now = self._now()
1202
+ with self._store.transaction() as db:
1203
+ cursor = db.execute(
1204
+ "INSERT OR IGNORE INTO notification_acknowledgements (event_id, account_id, "
1205
+ "acknowledged_by_id, acknowledged_by_login, acknowledged_at, note) "
1206
+ "VALUES (?, ?, ?, ?, ?, ?)",
1207
+ (event_id, account_id, principal.user_id, principal.login, ts(now), note_text),
1208
+ )
1209
+ if cursor.rowcount != 1:
1210
+ raise ConflictError("This event was already acknowledged.")
1211
+ stored = self._store.insert_audit_event(
1212
+ db,
1213
+ self._audit.build(
1214
+ AuditEventType.SECURITY_EVENT_ACKNOWLEDGED,
1215
+ actor=Actor.user(principal.user_id, principal.login),
1216
+ account_id=account_id,
1217
+ notification_event=event_id,
1218
+ notification_type=str(rows[0]["type"]),
1219
+ note=note_text,
1220
+ ),
1221
+ )
1222
+ self._audit.log_stored(stored)
1223
+ return {
1224
+ "event_id": event_id,
1225
+ "acknowledged_by": principal.login,
1226
+ "acknowledged_at": now.isoformat(),
1227
+ "meaning": "An administrator has seen this event. It does not resolve any violation.",
1228
+ }
1229
+
1230
+ def security_events(self, principal: Principal, account_id: int) -> list[dict[str, Any]]:
1231
+ """Recent critical and high organization events with their acknowledgement state."""
1232
+ require(principal, Permission.SECURITY_READ, account_id)
1233
+ rows = self._store.query(
1234
+ "SELECT e.event_id, e.type, e.severity, e.title, e.body, e.repository_id, "
1235
+ "e.resource_type, e.resource_id, e.last_occurred_at, e.occurrences, "
1236
+ "a.acknowledged_by_login, a.acknowledged_at FROM "
1237
+ "notification_events e LEFT JOIN notification_acknowledgements a ON a.event_id = "
1238
+ "e.event_id WHERE e.account_id = ? AND e.severity IN ('critical', 'high') "
1239
+ "ORDER BY e.last_occurred_at DESC LIMIT 50",
1240
+ (account_id,),
1241
+ )
1242
+ visible = self._visible(principal, account_id)
1243
+ return [
1244
+ {
1245
+ "id": str(r["event_id"]),
1246
+ "type": str(r["type"]),
1247
+ "severity": str(r["severity"]),
1248
+ "repository_id": r["repository_id"],
1249
+ "resource_type": str(r["resource_type"]),
1250
+ "resource_id": str(r["resource_id"]),
1251
+ "title": str(r["title"]),
1252
+ "body": str(r["body"]),
1253
+ "occurrences": int(r["occurrences"]),
1254
+ "last_occurred_at": req_dt(r["last_occurred_at"]).isoformat(),
1255
+ "acknowledged_by": r["acknowledged_by_login"],
1256
+ "acknowledged_at": req_dt(r["acknowledged_at"]).isoformat()
1257
+ if r["acknowledged_at"]
1258
+ else None,
1259
+ }
1260
+ for r in rows
1261
+ if r["repository_id"] is None or int(r["repository_id"]) in visible
1262
+ ]
1263
+
1264
+
1265
+ def _csv_cell(value: object) -> str:
1266
+ """CSV cells that a spreadsheet will not execute (formula injection)."""
1267
+ if value is None:
1268
+ return ""
1269
+ cell = json.dumps(value, sort_keys=True) if isinstance(value, dict | list) else str(value)
1270
+ if cell[:1] in ("=", "+", "-", "@", "\t", "\r"):
1271
+ cell = "'" + cell
1272
+ return cell