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,88 @@
1
+ """Effective policy cache invalidation (targeted, transactional).
2
+
3
+ ``repository_effective_policies`` holds one row per repository: the resolved
4
+ governance inputs, their fingerprint and a propagation ``state``:
5
+
6
+ ============== ===================================================================
7
+ ``up_to_date`` resolved from the current policy versions, group memberships,
8
+ exceptions and mode; valid until ``valid_until`` (the earliest
9
+ exception expiry it includes)
10
+ ``stale`` something that affects this repository changed; the next scan or
11
+ the propagation job resolves it again
12
+ ``syncing`` the propagation job is resolving it
13
+ ``error`` resolving failed; scans resolve directly and fail closed if that
14
+ fails too, and administrators are notified
15
+ ============== ===================================================================
16
+
17
+ Every governance change marks exactly the affected rows stale **in the same
18
+ transaction** as the change:
19
+
20
+ ================================= ==========================================
21
+ Change Affected repositories
22
+ ================================= ==========================================
23
+ organization policy, baseline, every repository of the organization
24
+ organization-wide exception
25
+ group policy, group exception, the group's members
26
+ group archived
27
+ group membership the repositories added or removed
28
+ repository policy, repository that repository
29
+ exception, onboarding mode
30
+ ================================= ==========================================
31
+
32
+ A row is therefore never ``up_to_date`` for inputs that no longer apply, and a
33
+ scan that finds its row anything but ``up_to_date`` resolves from the database.
34
+ """
35
+
36
+ import json
37
+ import sqlite3
38
+ from collections.abc import Iterable
39
+ from datetime import datetime
40
+
41
+
42
+ def invalidate_repositories(
43
+ db: sqlite3.Connection,
44
+ account_id: int,
45
+ repository_ids: Iterable[int] | None,
46
+ now: datetime,
47
+ ) -> int:
48
+ """Mark effective policies stale; ``None`` means every repository of the account."""
49
+ if repository_ids is None:
50
+ return db.execute(
51
+ "UPDATE repository_effective_policies SET state = 'stale', invalidated_at = ? "
52
+ "WHERE account_id = ?",
53
+ (now.timestamp(), int(account_id)),
54
+ ).rowcount
55
+ ids = sorted({int(i) for i in repository_ids})
56
+ if not ids:
57
+ return 0
58
+ return db.execute(
59
+ "UPDATE repository_effective_policies SET state = 'stale', invalidated_at = ? "
60
+ "WHERE account_id = ? AND repository_id IN (SELECT value FROM json_each(?))",
61
+ (now.timestamp(), int(account_id), json.dumps(ids)),
62
+ ).rowcount
63
+
64
+
65
+ def group_member_ids(db: sqlite3.Connection, account_id: int, group_id: str) -> list[int]:
66
+ return [
67
+ int(row["repository_id"])
68
+ for row in db.execute(
69
+ "SELECT repository_id FROM repository_group_members WHERE account_id = ? "
70
+ "AND group_id = ?",
71
+ (int(account_id), group_id),
72
+ ).fetchall()
73
+ ]
74
+
75
+
76
+ def invalidate_scope(
77
+ db: sqlite3.Connection, account_id: int, scope_type: str, scope_id: str, now: datetime
78
+ ) -> int:
79
+ """Invalidate the repositories a policy target or exception scope covers."""
80
+ if scope_type == "organization":
81
+ return invalidate_repositories(db, account_id, None, now)
82
+ if scope_type == "group":
83
+ return invalidate_repositories(
84
+ db, account_id, group_member_ids(db, account_id, scope_id), now
85
+ )
86
+ if scope_id.isdigit():
87
+ return invalidate_repositories(db, account_id, [int(scope_id)], now)
88
+ return 0
@@ -0,0 +1,216 @@
1
+ """Shared helpers for the organization governance services.
2
+
3
+ Tenant scoping
4
+ ==============
5
+
6
+ Every governance row carries the ``account_id`` of the organization (the GitHub
7
+ account that owns the installation) and, for repository-level rows, GitHub's
8
+ immutable repository ID. Services never accept a repository or group from a
9
+ request without resolving it *within* that account:
10
+
11
+ * :func:`account_repositories` lists the repositories the account's
12
+ installations have seen, one row per repository ID (a repository seen through
13
+ an older installation and again after a reinstallation is one repository);
14
+ * :func:`visible_repository_ids` narrows that to the repositories GitHub
15
+ reported to the signed-in session - the same rule as every other dashboard
16
+ view, so an organization role never reveals a repository its holder could not
17
+ open on GitHub.
18
+
19
+ A resource of another account is answered exactly like a missing one.
20
+ """
21
+
22
+ import json
23
+ import re
24
+ import sqlite3
25
+ import uuid
26
+ from collections.abc import Iterable, Sequence
27
+ from dataclasses import dataclass
28
+ from datetime import UTC, datetime
29
+
30
+ from commitguard.controlplane.access import Permission, Principal
31
+ from commitguard.controlplane.errors import (
32
+ InputValidationError,
33
+ NotFoundError,
34
+ PermissionDeniedError,
35
+ )
36
+ from commitguard.controlplane.results import clean_text
37
+ from commitguard.github.storage import SqliteStateStore
38
+
39
+ MAX_NAME_CHARS = 100
40
+ MAX_DESCRIPTION_CHARS = 500
41
+ MAX_REASON_CHARS = 500
42
+ #: Upper bound on repositories named in one request (bulk operations use jobs).
43
+ MAX_REPOSITORIES_PER_REQUEST = 5000
44
+ _HEX_ID = re.compile(r"\A[0-9a-f]{32}\Z")
45
+
46
+
47
+ def new_id() -> str:
48
+ return uuid.uuid4().hex
49
+
50
+
51
+ def ts(value: datetime) -> float:
52
+ return value.timestamp()
53
+
54
+
55
+ def dt(value: float | None) -> datetime | None:
56
+ return None if value is None else datetime.fromtimestamp(float(value), UTC)
57
+
58
+
59
+ def req_dt(value: float) -> datetime:
60
+ return datetime.fromtimestamp(float(value), UTC)
61
+
62
+
63
+ def is_hex_id(value: object) -> bool:
64
+ return isinstance(value, str) and bool(_HEX_ID.match(value))
65
+
66
+
67
+ def require(principal: Principal, permission: Permission, account_id: int) -> None:
68
+ """404 for a non-member (no disclosure), 403 for a member without the permission."""
69
+ if account_id not in principal.memberships:
70
+ raise NotFoundError()
71
+ if not principal.can(permission, account_id):
72
+ raise PermissionDeniedError()
73
+
74
+
75
+ def text(value: object, field: str, *, limit: int, required: bool = False) -> str | None:
76
+ if value is None or (isinstance(value, str) and not value.strip()):
77
+ if required:
78
+ raise InputValidationError(f"{field} is required", field=field)
79
+ return None
80
+ if not isinstance(value, str) or len(value) > limit:
81
+ raise InputValidationError(
82
+ f"{field} must be text of at most {limit} characters", field=field
83
+ )
84
+ return clean_text(value.strip(), limit)
85
+
86
+
87
+ def repository_ids(raw: object, field: str = "repository_ids") -> list[int]:
88
+ if not isinstance(raw, list) or not raw:
89
+ raise InputValidationError(
90
+ f"{field} must be a non-empty list of repository IDs", field=field
91
+ )
92
+ if len(raw) > MAX_REPOSITORIES_PER_REQUEST:
93
+ raise InputValidationError(
94
+ f"{field} may name at most {MAX_REPOSITORIES_PER_REQUEST} repositories", field=field
95
+ )
96
+ ids = []
97
+ for item in raw:
98
+ if not isinstance(item, int) or isinstance(item, bool) or not 1 <= item < 10**16:
99
+ raise InputValidationError(f"{field} must contain repository IDs", field=field)
100
+ ids.append(item)
101
+ return sorted(set(ids))
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class AccountRepository:
106
+ repository_id: int
107
+ installation_id: int
108
+ owner: str
109
+ name: str
110
+ default_branch: str | None
111
+ private: bool | None
112
+ archived: bool
113
+ listed: bool
114
+ installation_state: str
115
+
116
+ @property
117
+ def full_name(self) -> str:
118
+ return f"{self.owner}/{self.name}"
119
+
120
+ @property
121
+ def connected(self) -> bool:
122
+ return self.installation_state == "active" and self.listed
123
+
124
+
125
+ _ACCOUNT_REPOSITORIES = (
126
+ "SELECT repository_id, installation_id, owner, name, default_branch, private, archived, "
127
+ "listed, installation_state FROM (SELECT k.repository_id, k.installation_id, k.owner, k.name, "
128
+ "k.default_branch, k.private, k.archived, i.state AS installation_state, EXISTS (SELECT 1 FROM "
129
+ "installation_repositories ir WHERE ir.installation_id = k.installation_id AND "
130
+ "ir.repository_id = k.repository_id) AS listed, ROW_NUMBER() OVER (PARTITION BY "
131
+ "k.repository_id ORDER BY (i.state = 'active') DESC, k.last_seen_at DESC) AS rank "
132
+ "FROM known_repositories k JOIN installations i ON i.installation_id = k.installation_id "
133
+ "WHERE i.account_id = ? AND i.state != 'deleted') WHERE rank = 1"
134
+ )
135
+
136
+
137
+ def _repository(row: sqlite3.Row) -> AccountRepository:
138
+ return AccountRepository(
139
+ repository_id=int(row["repository_id"]),
140
+ installation_id=int(row["installation_id"]),
141
+ owner=str(row["owner"]),
142
+ name=str(row["name"]),
143
+ default_branch=row["default_branch"],
144
+ private=None if row["private"] is None else bool(row["private"]),
145
+ archived=bool(row["archived"]),
146
+ listed=bool(row["listed"]),
147
+ installation_state=str(row["installation_state"]),
148
+ )
149
+
150
+
151
+ def account_repositories(
152
+ store_or_db: SqliteStateStore | sqlite3.Connection, account_id: int
153
+ ) -> dict[int, AccountRepository]:
154
+ """Every repository of the account (including disconnected ones), by repository ID."""
155
+ params = (int(account_id),)
156
+ if isinstance(store_or_db, sqlite3.Connection):
157
+ rows = store_or_db.execute(_ACCOUNT_REPOSITORIES, params).fetchall()
158
+ else:
159
+ rows = store_or_db.query(_ACCOUNT_REPOSITORIES, params)
160
+ return {int(row["repository_id"]): _repository(row) for row in rows}
161
+
162
+
163
+ def account_repository(
164
+ store_or_db: SqliteStateStore | sqlite3.Connection, account_id: int, repository_id: int
165
+ ) -> AccountRepository | None:
166
+ # _ACCOUNT_REPOSITORIES is a module constant; nothing from the caller enters the SQL.
167
+ sql = f"SELECT * FROM ({_ACCOUNT_REPOSITORIES}) WHERE repository_id = ?" # noqa: S608 # nosec B608
168
+ params = (int(account_id), int(repository_id))
169
+ if isinstance(store_or_db, sqlite3.Connection):
170
+ row = store_or_db.execute(sql, params).fetchone()
171
+ else:
172
+ rows = store_or_db.query(sql, params)
173
+ row = rows[0] if rows else None
174
+ return _repository(row) if row is not None else None
175
+
176
+
177
+ def visible_repository_ids(
178
+ store: SqliteStateStore, principal: Principal, account_id: int
179
+ ) -> set[int]:
180
+ """Repository IDs of the account that GitHub reported to this session."""
181
+ installations = sorted(i for i, a in principal.installations.items() if a == account_id)
182
+ if not installations:
183
+ return set()
184
+ rows = store.query(
185
+ "SELECT DISTINCT repository_id FROM session_repositories WHERE session_hash = ? "
186
+ "AND installation_id IN (SELECT value FROM json_each(?))",
187
+ (principal.session_hash, json.dumps(installations)),
188
+ )
189
+ return {int(row["repository_id"]) for row in rows}
190
+
191
+
192
+ def require_visible_repositories(
193
+ store: SqliteStateStore, principal: Principal, account_id: int, ids: Iterable[int]
194
+ ) -> list[int]:
195
+ """All of ``ids`` must be the account's and visible to the caller, else 404."""
196
+ wanted = sorted(set(ids))
197
+ visible = visible_repository_ids(store, principal, account_id)
198
+ known = account_repositories(store, account_id)
199
+ missing = [i for i in wanted if i not in visible or i not in known]
200
+ if missing:
201
+ raise NotFoundError(
202
+ f"{len(missing)} of the selected repositories were not found in this organization."
203
+ )
204
+ return wanted
205
+
206
+
207
+ def json_list(value: str | None) -> list[object]:
208
+ if not value:
209
+ return []
210
+ parsed = json.loads(value)
211
+ return parsed if isinstance(parsed, list) else []
212
+
213
+
214
+ def chunks[T](items: Sequence[T], size: int) -> Iterable[Sequence[T]]:
215
+ for start in range(0, len(items), size):
216
+ yield items[start : start + size]