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,618 @@
1
+ """Policy simulation: what a draft policy would have decided, on real past scans.
2
+
3
+ ::
4
+
5
+ draft document
6
+
7
+
8
+ for every repository in the target's scope:
9
+ current governance inputs (resolver) ──► resolve_policy ──► policy set A
10
+ the same inputs with the draft layer ──► resolve_policy ──► policy set B
11
+
12
+ ▼ the findings stored with its recent scans, re-evaluated by the
13
+ real PolicyEvaluator under A and under B
14
+
15
+ projected: new blocks, new warnings, findings no longer blocked, unchanged
16
+
17
+ A simulation is **read-only analysis**. It never writes a policy version, never
18
+ touches a GitHub check, never changes violations or enforcement, and never
19
+ runs a detector: it reuses the recorded findings and the same policy engine
20
+ that produced the original results.
21
+
22
+ It is an **estimate**, and the result says so:
23
+
24
+ * it uses the scans that exist in the selected period, not future commits;
25
+ * scans recorded before Phase 8 did not store the repository's own
26
+ ``.commitguard.yaml`` overrides, so built-in defaults are assumed for them
27
+ and the result reports how many;
28
+ * repositories with no scan in the period are reported as "no data".
29
+
30
+ Large organizations run it in the background: a simulation is queued, claimed
31
+ by the maintenance loop with a lease, and bounded by
32
+ :data:`MAX_SCANS` scans and :data:`MAX_FINDINGS` findings.
33
+ """
34
+
35
+ import json
36
+ import sqlite3
37
+ from collections.abc import Callable, Sequence
38
+ from datetime import UTC, datetime, timedelta
39
+ from typing import Any
40
+
41
+ from pydantic import BaseModel, ConfigDict
42
+
43
+ from commitguard.audit.models import Actor, AuditEventType
44
+ from commitguard.config.schema import CommitGuardConfig, PolicyOverride
45
+ from commitguard.controlplane.access import Permission, Principal
46
+ from commitguard.controlplane.errors import ConflictError, InputValidationError, NotFoundError
47
+ from commitguard.controlplane.policies import PolicyTarget, PolicyTargetType, parse_document
48
+ from commitguard.controlplane.views import PolicyTargetView
49
+ from commitguard.core.decision import Action
50
+ from commitguard.core.result import (
51
+ Confidence,
52
+ DetectionResult,
53
+ Evidence,
54
+ EvidenceSource,
55
+ Finding,
56
+ Severity,
57
+ )
58
+ from commitguard.github.storage import SqliteStateStore
59
+ from commitguard.governance.cache import group_member_ids
60
+ from commitguard.governance.common import (
61
+ account_repositories,
62
+ dt,
63
+ is_hex_id,
64
+ new_id,
65
+ req_dt,
66
+ require,
67
+ ts,
68
+ visible_repository_ids,
69
+ )
70
+ from commitguard.governance.resolver import GovernanceResolver
71
+ from commitguard.observability.logging import get_logger
72
+ from commitguard.policies.evaluator import PolicyEvaluator
73
+ from commitguard.policies.governance import (
74
+ Enforcement,
75
+ GovernanceInputs,
76
+ PolicyLayer,
77
+ PolicyLevel,
78
+ RuleRequirement,
79
+ resolve_policy,
80
+ )
81
+ from commitguard.services.audit import AuditService
82
+
83
+ log = get_logger(__name__)
84
+
85
+ MAX_SCANS = 5_000
86
+ MAX_FINDINGS = 50_000
87
+ MAX_PERIOD_DAYS = 90
88
+ DEFAULT_PERIOD_DAYS = 30
89
+ MAX_OPEN_SIMULATIONS = 3
90
+ SIMULATION_LEASE = timedelta(minutes=10)
91
+ DISCLAIMER = (
92
+ "SIMULATION - an estimate from recorded scans in the selected period, not the current "
93
+ "security state and not a prediction of future commits."
94
+ )
95
+
96
+
97
+ class RepositoryImpact(BaseModel):
98
+ model_config = ConfigDict(frozen=True, extra="forbid")
99
+
100
+ repository_id: int
101
+ full_name: str | None # None: not visible to the caller
102
+ scans: int
103
+ new_blocks: int
104
+ new_warnings: int
105
+ no_longer_blocked: int
106
+
107
+
108
+ class SimulationResult(BaseModel):
109
+ model_config = ConfigDict(frozen=True, extra="forbid")
110
+
111
+ repositories_analyzed: int
112
+ repositories_without_data: int
113
+ scans_analyzed: int
114
+ findings_analyzed: int
115
+ new_blocks: int
116
+ new_warnings: int
117
+ no_longer_blocked: int
118
+ unchanged: int
119
+ scans_newly_blocked: int
120
+ scans_no_longer_blocked: int
121
+ scans_assumed_defaults: int # no recorded repository configuration (pre-Phase 8 scans)
122
+ most_affected: tuple[RepositoryImpact, ...]
123
+ truncated: bool
124
+ disclaimer: str = DISCLAIMER
125
+
126
+
127
+ class SimulationView(BaseModel):
128
+ model_config = ConfigDict(frozen=True, extra="forbid")
129
+
130
+ id: str
131
+ organization_id: int
132
+ target: PolicyTargetView
133
+ draft_id: str | None
134
+ current_version: int
135
+ state: str
136
+ parameters: dict[str, object]
137
+ requested_by: str | None
138
+ requested_at: datetime
139
+ started_at: datetime | None
140
+ completed_at: datetime | None
141
+ result: SimulationResult | None
142
+ error: str | None
143
+
144
+
145
+ def _config_from_overrides(document: str | None) -> tuple[list[CommitGuardConfig], bool]:
146
+ """The repository configuration recorded with a scan, if any."""
147
+ if not document:
148
+ return [], False
149
+ try:
150
+ raw = json.loads(document)
151
+ except ValueError:
152
+ return [], False
153
+ if not isinstance(raw, dict):
154
+ return [], False
155
+ policies: dict[str, PolicyOverride] = {}
156
+ for rule, fields in raw.items():
157
+ if not isinstance(fields, dict):
158
+ continue
159
+ try:
160
+ policies[rule] = PolicyOverride.model_validate(fields)
161
+ except ValueError:
162
+ continue
163
+ return [CommitGuardConfig(version=1, policies=policies)], True
164
+
165
+
166
+ def _finding(row: sqlite3.Row) -> Finding | None:
167
+ try:
168
+ evidence_items = json.loads(str(row["evidence"]))
169
+ except ValueError:
170
+ evidence_items = []
171
+ evidence = []
172
+ for item in evidence_items if isinstance(evidence_items, list) else []:
173
+ if not isinstance(item, dict):
174
+ continue
175
+ try:
176
+ evidence.append(
177
+ Evidence(
178
+ source=EvidenceSource(str(item.get("source", ""))),
179
+ value=str(item.get("value", ""))[:512] or "(recorded)",
180
+ line_number=item.get("line_number"),
181
+ )
182
+ )
183
+ except ValueError:
184
+ continue
185
+ if not evidence:
186
+ evidence = [Evidence(source=EvidenceSource.MESSAGE, value="(recorded finding)")]
187
+ try:
188
+ return Finding(
189
+ detector=str(row["detector"]),
190
+ rule_id=str(row["rule_id"]),
191
+ severity=Severity(row["severity"]),
192
+ confidence=Confidence(row["confidence"]),
193
+ title=str(row["title"]) or "finding",
194
+ message=str(row["message"]) or "recorded finding",
195
+ evidence=tuple(evidence),
196
+ commit_sha=row["commit_sha"],
197
+ remediation=str(row["remediation"]) or "See the violation page.",
198
+ )
199
+ except ValueError: # pragma: no cover - stored rows are validated on write
200
+ return None
201
+
202
+
203
+ class PolicySimulationService:
204
+ def __init__(
205
+ self,
206
+ store: SqliteStateStore,
207
+ audit: AuditService,
208
+ resolver: GovernanceResolver,
209
+ *,
210
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
211
+ ) -> None:
212
+ self._store = store
213
+ self._audit = audit
214
+ self._resolver = resolver
215
+ self._now = now
216
+
217
+ # -- requests --------------------------------------------------------- #
218
+ def create(
219
+ self,
220
+ principal: Principal,
221
+ account_id: int,
222
+ *,
223
+ target: PolicyTarget,
224
+ document: str,
225
+ draft_id: str | None,
226
+ period_days: object = None,
227
+ repository_ids: Sequence[int] | None = None,
228
+ ) -> SimulationView:
229
+ require(principal, Permission.POLICIES_WRITE, account_id)
230
+ days = DEFAULT_PERIOD_DAYS if period_days is None else period_days
231
+ if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= MAX_PERIOD_DAYS:
232
+ raise InputValidationError(
233
+ f"period_days must be between 1 and {MAX_PERIOD_DAYS}", field="period_days"
234
+ )
235
+ parse_document(document) # reject an invalid draft document early
236
+ now = self._now()
237
+ simulation_id = new_id()
238
+ current = self._resolver_policies_current(account_id, target)
239
+ parameters = {
240
+ "period_days": days,
241
+ "repository_ids": sorted(set(repository_ids)) if repository_ids else None,
242
+ }
243
+ with self._store.transaction() as db:
244
+ open_count = db.execute(
245
+ "SELECT COUNT(*) AS n FROM policy_simulations WHERE account_id = ? "
246
+ "AND state IN ('queued', 'running')",
247
+ (account_id,),
248
+ ).fetchone()["n"]
249
+ if int(open_count) >= MAX_OPEN_SIMULATIONS:
250
+ raise ConflictError(
251
+ "There are already simulations running for this organization. "
252
+ "Wait for them to finish."
253
+ )
254
+ db.execute(
255
+ "INSERT INTO policy_simulations (simulation_id, account_id, draft_id, "
256
+ "target_type, target_id, current_version, document, parameters, state, "
257
+ "requested_by_id, requested_by_login, requested_at) "
258
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)",
259
+ (
260
+ simulation_id,
261
+ account_id,
262
+ draft_id,
263
+ target.type.value,
264
+ target.id,
265
+ current,
266
+ document,
267
+ json.dumps(parameters),
268
+ principal.user_id,
269
+ principal.login,
270
+ ts(now),
271
+ ),
272
+ )
273
+ return self.get(principal, simulation_id)
274
+
275
+ def _resolver_policies_current(self, account_id: int, target: PolicyTarget) -> int:
276
+ rows = self._store.query(
277
+ "SELECT MAX(version) AS latest FROM organization_policy_versions WHERE account_id = ?"
278
+ if target.type is PolicyTargetType.ORGANIZATION
279
+ else "SELECT MAX(version) AS latest FROM scoped_policy_versions WHERE account_id = ? "
280
+ "AND target_type = ? AND target_id = ?",
281
+ (account_id,)
282
+ if target.type is PolicyTargetType.ORGANIZATION
283
+ else (account_id, target.type.value, target.id),
284
+ )
285
+ return int(rows[0]["latest"] or 0) if rows else 0
286
+
287
+ # -- reads ------------------------------------------------------------ #
288
+ def _row(self, simulation_id: str) -> sqlite3.Row:
289
+ if not is_hex_id(simulation_id):
290
+ raise NotFoundError()
291
+ rows = self._store.query(
292
+ "SELECT * FROM policy_simulations WHERE simulation_id = ?", (simulation_id,)
293
+ )
294
+ if not rows:
295
+ raise NotFoundError()
296
+ return rows[0]
297
+
298
+ def get(self, principal: Principal, simulation_id: str) -> SimulationView:
299
+ row = self._row(simulation_id)
300
+ account_id = int(row["account_id"])
301
+ require(principal, Permission.POLICIES_READ, account_id)
302
+ return self._view(row, principal)
303
+
304
+ def list_simulations(
305
+ self, principal: Principal, account_id: int, *, draft_id: str | None = None
306
+ ) -> list[SimulationView]:
307
+ require(principal, Permission.POLICIES_READ, account_id)
308
+ sql = "SELECT * FROM policy_simulations WHERE account_id = ?"
309
+ params: list[object] = [account_id]
310
+ if draft_id is not None:
311
+ sql += " AND draft_id = ?"
312
+ params.append(draft_id)
313
+ rows = self._store.query(sql + " ORDER BY requested_at DESC LIMIT 50", params)
314
+ return [self._view(row, principal) for row in rows]
315
+
316
+ def _view(self, row: sqlite3.Row, principal: Principal) -> SimulationView:
317
+ account_id = int(row["account_id"])
318
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
319
+ result = None
320
+ if row["result"]:
321
+ try:
322
+ parsed = SimulationResult.model_validate_json(str(row["result"]))
323
+ except ValueError:
324
+ parsed = None
325
+ if parsed is not None:
326
+ visible = visible_repository_ids(self._store, principal, account_id)
327
+ result = parsed.model_copy(
328
+ update={
329
+ "most_affected": tuple(
330
+ impact
331
+ if impact.repository_id in visible
332
+ else impact.model_copy(update={"full_name": None})
333
+ for impact in parsed.most_affected
334
+ )
335
+ }
336
+ )
337
+ with self._store.transaction() as db:
338
+ from commitguard.controlplane.policies import target_label
339
+
340
+ label = target_label(db, account_id, target) or "(removed)"
341
+ return SimulationView(
342
+ id=str(row["simulation_id"]),
343
+ organization_id=account_id,
344
+ target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
345
+ draft_id=row["draft_id"],
346
+ current_version=int(row["current_version"]),
347
+ state=str(row["state"]),
348
+ parameters=json.loads(str(row["parameters"])),
349
+ requested_by=row["requested_by_login"],
350
+ requested_at=req_dt(row["requested_at"]),
351
+ started_at=dt(row["started_at"]),
352
+ completed_at=dt(row["completed_at"]),
353
+ result=result,
354
+ error=row["error"],
355
+ )
356
+
357
+ # -- execution --------------------------------------------------------- #
358
+ def run_pending(self, limit: int = 1) -> int:
359
+ """Claim and run queued simulations (maintenance loop). Read-only analysis."""
360
+ now = self._now()
361
+ rows = self._store.query(
362
+ "SELECT simulation_id FROM policy_simulations WHERE state = 'queued' "
363
+ "OR (state = 'running' AND lease_expires_at < ?) ORDER BY requested_at LIMIT ?",
364
+ (ts(now), int(limit)),
365
+ )
366
+ done = 0
367
+ for row in rows:
368
+ simulation_id = str(row["simulation_id"])
369
+ with self._store.transaction() as db:
370
+ claimed = db.execute(
371
+ "UPDATE policy_simulations SET state = 'running', started_at = ?, "
372
+ "lease_expires_at = ?, attempts = attempts + 1 WHERE simulation_id = ? "
373
+ "AND (state = 'queued' OR (state = 'running' AND lease_expires_at < ?))",
374
+ (
375
+ ts(now),
376
+ ts(now + SIMULATION_LEASE),
377
+ simulation_id,
378
+ ts(now),
379
+ ),
380
+ ).rowcount
381
+ if claimed != 1:
382
+ continue
383
+ claimed_row = self._row(simulation_id)
384
+ requester = Actor.user(
385
+ int(claimed_row["requested_by_id"] or 0),
386
+ str(claimed_row["requested_by_login"] or "unknown"),
387
+ )
388
+ try:
389
+ result = self._simulate(claimed_row)
390
+ with self._store.transaction() as db:
391
+ db.execute(
392
+ "UPDATE policy_simulations SET state = 'completed', completed_at = ?, "
393
+ "result = ?, lease_expires_at = NULL WHERE simulation_id = ?",
394
+ (ts(self._now()), result.model_dump_json(), simulation_id),
395
+ )
396
+ stored = self._store.insert_audit_event(
397
+ db,
398
+ self._audit.build(
399
+ AuditEventType.POLICY_SIMULATED,
400
+ actor=requester,
401
+ account_id=int(claimed_row["account_id"]),
402
+ simulation=simulation_id,
403
+ scans=result.scans_analyzed,
404
+ new_blocks=result.new_blocks,
405
+ new_warnings=result.new_warnings,
406
+ no_longer_blocked=result.no_longer_blocked,
407
+ ),
408
+ )
409
+ self._audit.log_stored(stored)
410
+ done += 1
411
+ except Exception as exc: # noqa: BLE001 - a failed simulation changes nothing
412
+ log.error(
413
+ "policy_simulation_failed",
414
+ simulation=simulation_id,
415
+ error_type=type(exc).__name__,
416
+ )
417
+ with self._store.transaction() as db:
418
+ db.execute(
419
+ "UPDATE policy_simulations SET state = 'failed', completed_at = ?, "
420
+ "error = ?, lease_expires_at = NULL WHERE simulation_id = ?",
421
+ (
422
+ ts(self._now()),
423
+ f"simulation failed ({type(exc).__name__})",
424
+ simulation_id,
425
+ ),
426
+ )
427
+ return done
428
+
429
+ def _draft_layer(self, target: PolicyTarget, document: str, version: int) -> PolicyLayer | None:
430
+ floors, defaults = parse_document(document)
431
+ rules = {
432
+ rule: RuleRequirement(action=action, enforcement=Enforcement.DEFAULT)
433
+ for rule, action in defaults.items()
434
+ }
435
+ rules.update({rule: RuleRequirement(action=action) for rule, action in floors.items()})
436
+ if not rules:
437
+ return None
438
+ level = {
439
+ PolicyTargetType.ORGANIZATION: PolicyLevel.ORGANIZATION,
440
+ PolicyTargetType.GROUP: PolicyLevel.GROUP,
441
+ PolicyTargetType.REPOSITORY: PolicyLevel.REPOSITORY_POLICY,
442
+ }[target.type]
443
+ return PolicyLayer(
444
+ level=level,
445
+ source_id=target.id,
446
+ label=f"draft policy (would be v{version + 1})",
447
+ rules=rules,
448
+ )
449
+
450
+ @staticmethod
451
+ def _with_draft(
452
+ inputs: GovernanceInputs, target: PolicyTarget, draft: PolicyLayer | None
453
+ ) -> GovernanceInputs:
454
+ """The same inputs with the target's layer replaced by the draft."""
455
+ kept = tuple(
456
+ layer
457
+ for layer in inputs.layers
458
+ if not (
459
+ layer.source_id == target.id
460
+ and layer.level
461
+ in (
462
+ PolicyLevel.ORGANIZATION
463
+ if target.type is PolicyTargetType.ORGANIZATION
464
+ else PolicyLevel.GROUP
465
+ if target.type is PolicyTargetType.GROUP
466
+ else PolicyLevel.REPOSITORY_POLICY,
467
+ )
468
+ )
469
+ # The security baseline is a separate organization-level layer and stays.
470
+ or layer.source_id == "baseline"
471
+ )
472
+ return inputs.model_copy(update={"layers": (*kept, draft) if draft is not None else kept})
473
+
474
+ def _scope(self, db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> list[int]:
475
+ repositories = sorted(account_repositories(db, account_id))
476
+ if target.type is PolicyTargetType.GROUP:
477
+ members = set(group_member_ids(db, account_id, target.id))
478
+ return [r for r in repositories if r in members]
479
+ if target.type is PolicyTargetType.REPOSITORY:
480
+ wanted = int(target.id)
481
+ return [r for r in repositories if r == wanted]
482
+ return repositories
483
+
484
+ def _simulate(self, row: sqlite3.Row) -> SimulationResult:
485
+ account_id = int(row["account_id"])
486
+ target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
487
+ parameters = json.loads(str(row["parameters"]))
488
+ period = timedelta(days=int(parameters.get("period_days", DEFAULT_PERIOD_DAYS)))
489
+ wanted = parameters.get("repository_ids")
490
+ since = self._now() - period
491
+ draft_layer = self._draft_layer(target, str(row["document"]), int(row["current_version"]))
492
+
493
+ with self._store.transaction() as db:
494
+ scope = self._scope(db, account_id, target)
495
+ if wanted:
496
+ scope = [r for r in scope if r in set(wanted)]
497
+ names = {
498
+ repository_id: repository.full_name
499
+ for repository_id, repository in account_repositories(db, account_id).items()
500
+ }
501
+ policy_sets = {}
502
+ for repository_id in scope:
503
+ resolved = self._resolver.resolve_in(db, account_id, repository_id)
504
+ policy_sets[repository_id] = (
505
+ resolved.inputs,
506
+ self._with_draft(resolved.inputs, target, draft_layer),
507
+ )
508
+
509
+ totals = dict.fromkeys(
510
+ (
511
+ "new_blocks",
512
+ "new_warnings",
513
+ "no_longer_blocked",
514
+ "unchanged",
515
+ "scans",
516
+ "findings",
517
+ "scans_newly_blocked",
518
+ "scans_no_longer_blocked",
519
+ "assumed",
520
+ ),
521
+ 0,
522
+ )
523
+ impacts: list[RepositoryImpact] = []
524
+ repositories_without_data = 0
525
+ repositories_analyzed = 0
526
+ truncated = False
527
+ for repository_id in scope:
528
+ if totals["scans"] >= MAX_SCANS or totals["findings"] >= MAX_FINDINGS:
529
+ truncated = True # the remaining repositories were not analyzed
530
+ break
531
+ scans = self._store.query(
532
+ "SELECT j.job_id, j.repository_policies, j.group_key, j.sequence FROM scan_jobs j "
533
+ "WHERE j.repository_id = ? AND j.state IN ('passed', 'failed') "
534
+ "AND j.completed_at >= ? AND NOT EXISTS (SELECT 1 FROM scan_jobs n WHERE "
535
+ "n.repository_id = j.repository_id AND n.group_key = j.group_key "
536
+ "AND n.state IN ('passed', 'failed') AND n.sequence > j.sequence) "
537
+ "ORDER BY j.sequence DESC LIMIT 200",
538
+ (repository_id, ts(since)),
539
+ )
540
+ if not scans:
541
+ repositories_without_data += 1
542
+ continue
543
+ repositories_analyzed += 1
544
+ current_inputs, draft_inputs = policy_sets[repository_id]
545
+ impact = {"new_blocks": 0, "new_warnings": 0, "no_longer_blocked": 0, "scans": 0}
546
+ # Scans of one repository usually share a configuration: resolve each once.
547
+ policy_cache: dict[str | None, tuple[Any, Any, bool]] = {}
548
+ for scan in scans:
549
+ key = scan["repository_policies"]
550
+ if key not in policy_cache:
551
+ configs, recorded = _config_from_overrides(key)
552
+ policy_cache[key] = (
553
+ resolve_policy(current_inputs, configs).policy_set(),
554
+ resolve_policy(draft_inputs, configs).policy_set(),
555
+ recorded,
556
+ )
557
+ current_policy, draft_policy, recorded = policy_cache[key]
558
+ if not recorded:
559
+ totals["assumed"] += 1
560
+ findings = self._store.query(
561
+ "SELECT detector, rule_id, severity, confidence, title, message, "
562
+ "remediation, evidence, commit_sha FROM findings WHERE job_id = ? LIMIT 2000",
563
+ (scan["job_id"],),
564
+ )
565
+ rebuilt = [f for f in (_finding(r) for r in findings) if f is not None]
566
+ if not rebuilt:
567
+ continue
568
+ totals["scans"] += 1
569
+ impact["scans"] += 1
570
+ totals["findings"] += len(rebuilt)
571
+ result = DetectionResult(commit_sha=None, detectors_run=(), findings=tuple(rebuilt))
572
+ before = PolicyEvaluator(current_policy).evaluate(result)
573
+ after = PolicyEvaluator(draft_policy).evaluate(result)
574
+ for old, new in zip(before.explanations, after.explanations, strict=True):
575
+ if old.action is new.action:
576
+ totals["unchanged"] += 1
577
+ elif new.action is Action.BLOCK:
578
+ totals["new_blocks"] += 1
579
+ impact["new_blocks"] += 1
580
+ elif new.action is Action.WARN and old.action is Action.ALLOW:
581
+ totals["new_warnings"] += 1
582
+ impact["new_warnings"] += 1
583
+ else:
584
+ totals["no_longer_blocked"] += 1
585
+ impact["no_longer_blocked"] += 1
586
+ if before.action is not Action.BLOCK and after.action is Action.BLOCK:
587
+ totals["scans_newly_blocked"] += 1
588
+ elif before.action is Action.BLOCK and after.action is not Action.BLOCK:
589
+ totals["scans_no_longer_blocked"] += 1
590
+ if impact["new_blocks"] or impact["new_warnings"] or impact["no_longer_blocked"]:
591
+ impacts.append(
592
+ RepositoryImpact(
593
+ repository_id=repository_id,
594
+ full_name=names.get(repository_id),
595
+ scans=impact["scans"],
596
+ new_blocks=impact["new_blocks"],
597
+ new_warnings=impact["new_warnings"],
598
+ no_longer_blocked=impact["no_longer_blocked"],
599
+ )
600
+ )
601
+ impacts.sort(
602
+ key=lambda i: i.new_blocks + i.new_warnings + i.no_longer_blocked, reverse=True
603
+ )
604
+ return SimulationResult(
605
+ repositories_analyzed=repositories_analyzed,
606
+ repositories_without_data=repositories_without_data,
607
+ scans_analyzed=totals["scans"],
608
+ findings_analyzed=totals["findings"],
609
+ new_blocks=totals["new_blocks"],
610
+ new_warnings=totals["new_warnings"],
611
+ no_longer_blocked=totals["no_longer_blocked"],
612
+ unchanged=totals["unchanged"],
613
+ scans_newly_blocked=totals["scans_newly_blocked"],
614
+ scans_no_longer_blocked=totals["scans_no_longer_blocked"],
615
+ scans_assumed_defaults=totals["assumed"],
616
+ most_affected=tuple(impacts[:10]),
617
+ truncated=truncated,
618
+ )