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,628 @@
1
+ """ScanWorker: turns a stored scan job into a GitHub Check Run.
2
+
3
+ ::
4
+
5
+ claim job -> authorize installation + repository (down-scoped token, ID lookup)
6
+ -> newest event for this pull request, PR still open? (else: cancelled, no check)
7
+ -> claim the check slot (repository, SHA, name) (a newer job wins; older jobs stop)
8
+ -> create/refresh Check Run "queued"
9
+ -> fetch commit metadata into the mirror (no checkout, no code execution)
10
+ -> plan commit range + trusted policy (same code as the GitHub Action)
11
+ -> verify planned head == check SHA (TOCTOU guard)
12
+ -> Check Run "in_progress" -> scan -> Check Run "completed"
13
+ -> job state + audit events + metrics
14
+
15
+ Stale writes: every Check Run write happens under a per-slot lock after
16
+ verifying that this job still owns the slot. A job for the same commit that
17
+ started later takes ownership, so an older, slower scan can never overwrite a
18
+ newer result. Check Runs are always created with the exact SHA that was
19
+ scanned, so a result can never be attached to a different commit.
20
+
21
+ Failures are classified - authorization, configuration, infrastructure,
22
+ timeout, internal - recorded as job state ``error`` (distinct from a policy
23
+ ``failed``) and, whenever a Check Run exists, published as a failing check:
24
+ CommitGuard never reports success for a commit it could not evaluate.
25
+
26
+ Executions: a job is one *execution* of a logical scan. Re-runs (GitHub's
27
+ "Re-run"), manual scans and automatic retries are new executions of the same
28
+ scan that publish to the same (repository, SHA, check name) slot; earlier
29
+ executions stay stored unchanged. Merge group jobs scan ``base..merge group``
30
+ and publish to the merge group commit, which is the SHA the merge queue waits
31
+ on; a merge group GitHub has already destroyed is not scanned.
32
+ """
33
+
34
+ import sqlite3
35
+ import threading
36
+ import zlib
37
+ from collections.abc import Callable
38
+ from dataclasses import dataclass
39
+ from datetime import UTC, datetime
40
+
41
+ from commitguard.audit.models import AuditEventType
42
+ from commitguard.ci.context import CIEventKind
43
+ from commitguard.config.sources import MandatoryPolicy
44
+ from commitguard.controlplane.results import ScanResultRecorder, merge_queue_failure
45
+ from commitguard.core.decision import Action
46
+ from commitguard.core.result import Severity
47
+ from commitguard.exceptions.base import CommitGuardError
48
+ from commitguard.exceptions.configuration import ConfigurationError
49
+ from commitguard.exceptions.git import GitError
50
+ from commitguard.exceptions.service import InfrastructureError, ScanError, StaleScanError
51
+ from commitguard.git.repository import Repository
52
+ from commitguard.github.check_runs import (
53
+ completed_output,
54
+ error_output,
55
+ in_progress_output,
56
+ queued_output,
57
+ )
58
+ from commitguard.github.checks import (
59
+ CheckRunConclusion,
60
+ CheckRunOutput,
61
+ CheckRunStatus,
62
+ check_run_create_payload,
63
+ check_run_update_payload,
64
+ )
65
+ from commitguard.github.client import GitHubClient
66
+ from commitguard.github.errors import (
67
+ AuthenticationError,
68
+ AuthorizationError,
69
+ GitHubAPIError,
70
+ safe_text,
71
+ )
72
+ from commitguard.github.installations import AuthorizedRepository, InstallationService
73
+ from commitguard.github.repositories import FetchTimeoutError, MirrorManager
74
+ from commitguard.github.storage import (
75
+ JobState,
76
+ MergeGroupState,
77
+ ScanJob,
78
+ ScanTrigger,
79
+ SqliteStateStore,
80
+ )
81
+ from commitguard.notifications.deduplication import domain_key
82
+ from commitguard.notifications.models import NotificationEvent, NotificationType
83
+ from commitguard.notifications.outbox import account_for_installation, emit
84
+ from commitguard.observability.logging import correlation, get_logger
85
+ from commitguard.observability.metrics import (
86
+ MERGE_GROUPS_FAILED,
87
+ MERGE_GROUPS_SCANNED,
88
+ POLICY_VIOLATIONS,
89
+ SCANS_CANCELLED,
90
+ SCANS_COMPLETED,
91
+ SCANS_FAILED,
92
+ SCANS_STARTED,
93
+ Metrics,
94
+ )
95
+ from commitguard.policies.governance import EffectivePolicy, GovernanceInputs
96
+ from commitguard.rules.matcher import CompiledRules
97
+ from commitguard.services.audit import AuditService
98
+ from commitguard.services.ci import DEFAULT_CI_MAX_COMMITS
99
+ from commitguard.services.enforcement import FailureKind
100
+ from commitguard.services.scan import ScanRequest, ScanResult, ScanService
101
+
102
+ log = get_logger(__name__)
103
+
104
+ JOB_LEASE_SECONDS = 1800.0
105
+ MAX_JOB_ATTEMPTS = 3
106
+ _LOCK_STRIPES = 64
107
+ #: Cancellation reasons meaning a newer execution superseded this one (result ``stale``).
108
+ SUPERSEDED_NEWER_EVENT = "a newer event for this pull request is waiting to be scanned"
109
+ SUPERSEDED_CHECK_OWNER = "a newer scan owns the check for this commit"
110
+ MERGE_GROUP_GONE = "the merge queue no longer waits for this merge group"
111
+ _SUPERSEDED = frozenset({SUPERSEDED_NEWER_EVENT, SUPERSEDED_CHECK_OWNER, MERGE_GROUP_GONE})
112
+
113
+
114
+ type PolicyResolver = Callable[[int], tuple[MandatoryPolicy | None, int | None]]
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class ScanGovernance:
119
+ """Organization governance for one scan (see commitguard.governance.resolver)."""
120
+
121
+ inputs: GovernanceInputs
122
+ organization_policy_version: int | None
123
+ fingerprint: str
124
+ #: Builds the immutable record stored with the scan from the resolved effective policy.
125
+ record: Callable[[EffectivePolicy | None], str]
126
+ #: The organization's rules (bundled rules plus its identity data), when it has any.
127
+ rules: CompiledRules | None = None
128
+ rules_version: str | None = None
129
+
130
+
131
+ type GovernanceResolverFn = Callable[[int, int], ScanGovernance | None]
132
+
133
+
134
+ @dataclass
135
+ class _Progress:
136
+ auth: AuthorizedRepository | None = None
137
+ check_run_id: int | None = None
138
+
139
+
140
+ class ScanWorker:
141
+ def __init__(
142
+ self,
143
+ *,
144
+ store: SqliteStateStore,
145
+ installations: InstallationService,
146
+ client: GitHubClient,
147
+ mirrors: MirrorManager,
148
+ audit: AuditService,
149
+ metrics: Metrics,
150
+ scan_service: ScanService | None = None,
151
+ mandatory_policy: MandatoryPolicy | None = None,
152
+ policy_resolver: PolicyResolver | None = None,
153
+ governance_resolver: GovernanceResolverFn | None = None,
154
+ recorder: ScanResultRecorder | None = None,
155
+ max_commits: int = DEFAULT_CI_MAX_COMMITS,
156
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
157
+ ) -> None:
158
+ self._store = store
159
+ self._installations = installations
160
+ self._client = client
161
+ self._mirrors = mirrors
162
+ self._audit = audit
163
+ self._metrics = metrics
164
+ self._scans = scan_service or ScanService()
165
+ # The floor for an installation: service policy + its organisation's policy.
166
+ self._policy_resolver: PolicyResolver = policy_resolver or (
167
+ lambda _installation_id: (mandatory_policy, None)
168
+ )
169
+ # Organization governance per repository (GitHub App); replaces the floor above.
170
+ self._governance_resolver = governance_resolver
171
+ self._recorder = recorder or ScanResultRecorder(store, audit, now=now)
172
+ self._max_commits = max_commits
173
+ self._now = now
174
+ self._locks = tuple(threading.Lock() for _ in range(_LOCK_STRIPES))
175
+
176
+ # ------------------------------------------------------------------ #
177
+ def process(self, job_id: str) -> JobState | None:
178
+ """Run one job. Returns its final state, or None if it was not claimable."""
179
+ outcome = self._store.claim_job_outcome(
180
+ job_id, self._now(), JOB_LEASE_SECONDS, MAX_JOB_ATTEMPTS
181
+ )
182
+ if outcome.exhausted is not None:
183
+ self._exhausted(outcome.exhausted)
184
+ return None
185
+ job = outcome.job
186
+ if job is None:
187
+ return None
188
+ with correlation(
189
+ job_id=job.job_id,
190
+ delivery_id=job.delivery_id,
191
+ installation_id=job.installation_id,
192
+ repository=job.repository.full_name,
193
+ ):
194
+ self._metrics.increment(SCANS_STARTED)
195
+ progress = _Progress()
196
+ try:
197
+ return self._run(job, progress)
198
+ except StaleScanError as exc:
199
+ return self._cancel(job, str(exc))
200
+ except (AuthorizationError, AuthenticationError) as exc:
201
+ return self._fail(job, progress, FailureKind.AUTHORIZATION, exc)
202
+ except ConfigurationError as exc:
203
+ return self._fail(job, progress, FailureKind.CONFIGURATION, exc)
204
+ except FetchTimeoutError as exc:
205
+ return self._fail(job, progress, FailureKind.TIMEOUT, exc)
206
+ except (InfrastructureError, GitHubAPIError, GitError, ScanError) as exc:
207
+ return self._fail(job, progress, FailureKind.INFRASTRUCTURE, exc)
208
+ except Exception as exc: # noqa: BLE001 - any failure must fail closed
209
+ return self._fail(job, progress, FailureKind.INTERNAL, exc)
210
+
211
+ # ------------------------------------------------------------------ #
212
+ def _slot(self, job: ScanJob, repository_id: int) -> tuple[int, int, str, str]:
213
+ return (job.installation_id, repository_id, job.head_sha, job.check_name)
214
+
215
+ def _lock_for(self, slot: tuple[int, int, str, str]) -> threading.Lock:
216
+ index = zlib.crc32(repr(slot).encode("utf-8")) % _LOCK_STRIPES
217
+ return self._locks[index]
218
+
219
+ def _run(self, job: ScanJob, progress: _Progress) -> JobState:
220
+ if not self._store.monitoring_enabled(job.installation_id, job.repository.id):
221
+ raise StaleScanError("CommitGuard monitoring is paused for this repository")
222
+ if job.event == "merge_group":
223
+ group = self._store.get_merge_group(
224
+ job.installation_id, job.repository.id, job.head_sha
225
+ )
226
+ if group is None or group.state is MergeGroupState.DESTROYED:
227
+ raise StaleScanError(MERGE_GROUP_GONE)
228
+ if job.trigger in (ScanTrigger.MANUAL, ScanTrigger.RERUN, ScanTrigger.RETRY):
229
+ self._audit_job(
230
+ AuditEventType.SCAN_STARTED,
231
+ job,
232
+ trigger=job.trigger.value,
233
+ execution=job.execution,
234
+ requested_by=job.requested_by,
235
+ )
236
+ auth = self._installations.authorize(job.installation_id, job.repository)
237
+ progress.auth = auth
238
+ repository = auth.repository
239
+ token = auth.token.token
240
+
241
+ if job.pull_request_number is not None:
242
+ latest = self._store.latest_group_sequence(
243
+ job.installation_id, repository.id, job.group_key
244
+ )
245
+ if latest > job.sequence:
246
+ raise StaleScanError(SUPERSEDED_NEWER_EVENT)
247
+ pull_request = self._client.get_pull_request(token, repository, job.pull_request_number)
248
+ if pull_request.state != "open":
249
+ raise StaleScanError("the pull request is no longer open")
250
+ if pull_request.head.sha != job.head_sha:
251
+ # The API can lag behind webhooks, so this alone does not cancel the scan:
252
+ # a newer head arrives as its own event, and this result is attached to
253
+ # exactly the commit that was scanned.
254
+ log.info("pull_request_head_differs", commit=job.head_sha[:12])
255
+
256
+ slot = self._slot(job, repository.id)
257
+ with self._lock_for(slot):
258
+ claim = self._store.claim_check(*slot, job.sequence, self._now())
259
+ if not claim.owned:
260
+ raise StaleScanError(SUPERSEDED_CHECK_OWNER)
261
+ queued = queued_output(self._describe(job))
262
+ if claim.check_run_id is None:
263
+ created = self._client.create_check_run(
264
+ token,
265
+ repository,
266
+ check_run_create_payload(
267
+ name=job.check_name,
268
+ head_sha=job.head_sha,
269
+ external_id=job.job_id,
270
+ output=queued,
271
+ ),
272
+ )
273
+ if created.head_sha != job.head_sha:
274
+ raise ScanError("GitHub attached the check run to a different commit")
275
+ if not self._store.set_check_run_id(*slot, job.sequence, created.id):
276
+ raise StaleScanError(SUPERSEDED_CHECK_OWNER)
277
+ progress.check_run_id = created.id
278
+ else:
279
+ progress.check_run_id = claim.check_run_id
280
+ self._client.update_check_run(
281
+ token,
282
+ repository,
283
+ claim.check_run_id,
284
+ check_run_update_payload(CheckRunStatus.QUEUED, queued),
285
+ )
286
+ self._store.update_job(job.job_id, self._now(), check_run_id=progress.check_run_id)
287
+
288
+ context = job.context
289
+ if context.event in (CIEventKind.PULL_REQUEST, CIEventKind.MERGE_GROUP):
290
+ # Merge groups: base..merge group commit, policy from the base (the trusted
291
+ # target branch), results on the merge group commit the queue waits on.
292
+ required = [s for s in (context.base_sha, context.head_sha) if s]
293
+ optional: list[str] = []
294
+ branches: list[str] = []
295
+ else:
296
+ required = [context.after_sha] if context.after_sha else []
297
+ optional = [context.before_sha] if context.before_sha else []
298
+ branches = [auth.default_branch] if auth.default_branch else []
299
+ # The default branch used to limit a new branch's range comes from the API.
300
+ context = context.model_copy(update={"default_branch": auth.default_branch})
301
+ mirror = self._mirrors.prepare(
302
+ job.installation_id,
303
+ repository,
304
+ token,
305
+ required=required,
306
+ optional=optional,
307
+ branches=branches,
308
+ )
309
+
310
+ governance = (
311
+ self._governance_resolver(job.installation_id, repository.id)
312
+ if self._governance_resolver is not None
313
+ else None
314
+ )
315
+ if governance is not None:
316
+ # Resolution failures raise and fail the scan closed: never scan without policy.
317
+ mandatory, organization_policy_version = None, governance.organization_policy_version
318
+ else:
319
+ mandatory, organization_policy_version = self._policy_resolver(job.installation_id)
320
+ request = ScanRequest(
321
+ repository=mirror,
322
+ context=context,
323
+ max_commits=self._max_commits,
324
+ mandatory_policy=mandatory,
325
+ governance=governance.inputs if governance is not None else None,
326
+ rules=governance.rules if governance is not None else None,
327
+ rules_version=governance.rules_version if governance is not None else None,
328
+ )
329
+ plan = self._scans.plan(request)
330
+ if plan.range.head is not None and plan.range.head != job.head_sha:
331
+ raise ScanError("the planned commit does not match the commit the check belongs to")
332
+ self._publish(
333
+ job,
334
+ progress,
335
+ CheckRunStatus.IN_PROGRESS,
336
+ in_progress_output(len(plan.range.commits), str(plan.policy_source)),
337
+ )
338
+ result = self._scans.execute(request, plan)
339
+ with correlation(scan_id=result.metadata.scan_id):
340
+ conclusion, output = completed_output(result)
341
+ self._publish(job, progress, CheckRunStatus.COMPLETED, output, conclusion)
342
+ return self._record_result(
343
+ job, result, conclusion, mirror, organization_policy_version, governance
344
+ )
345
+
346
+ def _describe(self, job: ScanJob) -> str:
347
+ if job.event == "merge_group":
348
+ return f"the merge queue's merge group at {job.head_sha[:12]}"
349
+ if job.pull_request_number is not None:
350
+ return f"pull request #{job.pull_request_number} at {job.head_sha[:12]}"
351
+ return f"the push of {job.head_sha[:12]}"
352
+
353
+ def _publish(
354
+ self,
355
+ job: ScanJob,
356
+ progress: _Progress,
357
+ status: CheckRunStatus,
358
+ output: CheckRunOutput,
359
+ conclusion: CheckRunConclusion | None = None,
360
+ ) -> None:
361
+ if progress.auth is None or progress.check_run_id is None:
362
+ raise ScanError("no check run to update")
363
+ slot = self._slot(job, progress.auth.repository.id)
364
+ with self._lock_for(slot):
365
+ if self._store.check_owner(*slot) != job.sequence:
366
+ raise StaleScanError(SUPERSEDED_CHECK_OWNER)
367
+ self._client.update_check_run(
368
+ progress.auth.token.token,
369
+ progress.auth.repository,
370
+ progress.check_run_id,
371
+ check_run_update_payload(status, output, conclusion),
372
+ )
373
+
374
+ def _record_result(
375
+ self,
376
+ job: ScanJob,
377
+ result: ScanResult,
378
+ conclusion: CheckRunConclusion,
379
+ repository: Repository,
380
+ organization_policy_version: int | None,
381
+ governance: ScanGovernance | None = None,
382
+ ) -> JobState:
383
+ stats = result.statistics
384
+ state = JobState.PASSED if result.enforcement.allowed else JobState.FAILED
385
+ # Job state, findings and the violation lifecycle change in one transaction.
386
+ self._recorder.record_completed(
387
+ job,
388
+ result,
389
+ state=state,
390
+ conclusion=conclusion.value,
391
+ repository=repository,
392
+ organization_policy_version=organization_policy_version,
393
+ governance_record=governance.record(result.effective) if governance else None,
394
+ governance_fingerprint=governance.fingerprint if governance else None,
395
+ )
396
+ self._audit_job(
397
+ AuditEventType.REPOSITORY_SCANNED,
398
+ job,
399
+ action=result.action,
400
+ commits_scanned=stats.commits_scanned,
401
+ rules_version=result.metadata.rules_version[:16],
402
+ policy_version=result.metadata.policy_version[:16],
403
+ organization_policy_version=organization_policy_version,
404
+ trigger=job.trigger.value,
405
+ execution=job.execution,
406
+ )
407
+ self._audit_job(
408
+ AuditEventType.SCAN_PASSED if state is JobState.PASSED else AuditEventType.SCAN_FAILED,
409
+ job,
410
+ action=result.action,
411
+ violations=stats.violations,
412
+ warnings=stats.warnings,
413
+ conclusion=conclusion.value,
414
+ trigger=job.trigger.value,
415
+ execution=job.execution,
416
+ organization_policy_version=organization_policy_version,
417
+ )
418
+ if job.event == "merge_group":
419
+ self._metrics.increment(MERGE_GROUPS_SCANNED, result=result.action.value)
420
+ self._audit_job(
421
+ AuditEventType.MERGE_GROUP_PASSED
422
+ if state is JobState.PASSED
423
+ else AuditEventType.MERGE_GROUP_BLOCKED,
424
+ job,
425
+ action=result.action,
426
+ violations=stats.violations,
427
+ pull_requests=self._merge_group_prs(job),
428
+ )
429
+ if state is JobState.FAILED:
430
+ self._metrics.increment(MERGE_GROUPS_FAILED, reason="blocked")
431
+ blocked = [
432
+ f
433
+ for commit in result.report.commits
434
+ for f in commit.findings
435
+ if f.action is Action.BLOCK
436
+ ]
437
+ if blocked:
438
+ self._metrics.increment(POLICY_VIOLATIONS, len(blocked))
439
+ self._audit_job(
440
+ AuditEventType.POLICY_VIOLATION,
441
+ job,
442
+ action=Action.BLOCK,
443
+ rules=",".join(sorted({f.finding.rule_id for f in blocked})),
444
+ findings=len(blocked),
445
+ fingerprints=",".join(sorted(f.fingerprint[:16] for f in blocked)[:20]),
446
+ )
447
+ if result.plan.policy_weakenings:
448
+ self._audit_job(
449
+ AuditEventType.POLICY_MODIFICATION,
450
+ job,
451
+ changes="; ".join(result.plan.policy_weakenings),
452
+ )
453
+ self._metrics.increment(SCANS_COMPLETED, result=result.action.value)
454
+ log.info(
455
+ "scan_completed",
456
+ commit=job.head_sha[:12],
457
+ result=result.action.value,
458
+ conclusion=conclusion.value,
459
+ commits=stats.commits_scanned,
460
+ violations=stats.violations,
461
+ warnings=stats.warnings,
462
+ )
463
+ return state
464
+
465
+ def _merge_group_prs(self, job: ScanJob) -> str | None:
466
+ group = self._store.get_merge_group(job.installation_id, job.repository.id, job.head_sha)
467
+ if group is None or not group.pull_requests:
468
+ return None
469
+ return ",".join(f"#{n}" for n in group.pull_requests)
470
+
471
+ def _cancel(self, job: ScanJob, reason: str) -> JobState:
472
+ self._store.update_job(
473
+ job.job_id,
474
+ self._now(),
475
+ state=JobState.CANCELLED,
476
+ message=safe_text(reason),
477
+ failure_kind="stale" if reason in _SUPERSEDED else None,
478
+ lease_expires_at=None,
479
+ completed_at=self._now().timestamp(),
480
+ )
481
+ self._metrics.increment(SCANS_CANCELLED)
482
+ self._audit_job(AuditEventType.SCAN_CANCELLED, job, reason=reason)
483
+ log.info("scan_cancelled", commit=job.head_sha[:12], reason=reason)
484
+ return JobState.CANCELLED
485
+
486
+ def _fail(
487
+ self, job: ScanJob, progress: _Progress, kind: FailureKind, exc: BaseException
488
+ ) -> JobState:
489
+ if isinstance(exc, CommitGuardError):
490
+ reason = safe_text(str(exc), 500)
491
+ else:
492
+ reason = f"unexpected error ({type(exc).__name__})"
493
+ now = self._now()
494
+ with self._store.transaction() as db:
495
+ self._store.update_job_in(
496
+ db,
497
+ job.job_id,
498
+ now,
499
+ state=JobState.ERROR,
500
+ failure_kind=kind.value,
501
+ message=reason,
502
+ lease_expires_at=None,
503
+ completed_at=now.timestamp(),
504
+ )
505
+ self._emit_failure_notifications(db, job, reason, now)
506
+ self._metrics.increment(SCANS_FAILED, kind=kind.value)
507
+ if job.event == "merge_group":
508
+ self._metrics.increment(MERGE_GROUPS_FAILED, reason="error")
509
+ self._audit_job(
510
+ AuditEventType.MERGE_GROUP_SCAN_FAILED,
511
+ job,
512
+ failure_kind=kind.value,
513
+ reason=reason,
514
+ pull_requests=self._merge_group_prs(job),
515
+ )
516
+ log.warning(
517
+ "scan_error",
518
+ exc=exc if isinstance(exc, CommitGuardError) else None,
519
+ failure_kind=kind.value,
520
+ error_type=type(exc).__name__,
521
+ commit=job.head_sha[:12],
522
+ )
523
+ event_type = {
524
+ FailureKind.AUTHORIZATION: AuditEventType.AUTHORIZATION_DENIED,
525
+ FailureKind.CONFIGURATION: AuditEventType.CONFIGURATION_ERROR,
526
+ }.get(kind, AuditEventType.SCAN_ERROR)
527
+ self._audit_job(event_type, job, failure_kind=kind.value, reason=reason)
528
+ if progress.auth is not None and progress.check_run_id is not None:
529
+ conclusion, output = error_output(kind, reason)
530
+ try:
531
+ self._publish(job, progress, CheckRunStatus.COMPLETED, output, conclusion)
532
+ except StaleScanError:
533
+ pass # a newer scan owns the check and will publish its own result
534
+ except Exception as publish_error: # noqa: BLE001 - best effort; already failed closed
535
+ log.error(
536
+ "check_run_failure_not_published",
537
+ error_type=type(publish_error).__name__,
538
+ commit=job.head_sha[:12],
539
+ )
540
+ return JobState.ERROR
541
+
542
+ def _emit_failure_notifications(
543
+ self, db: sqlite3.Connection, job: ScanJob, reason: str, now: datetime
544
+ ) -> None:
545
+ """Operational failures that someone is waiting on (never a security decision)."""
546
+ account_id = account_for_installation(db, job.installation_id)
547
+ if account_id is None:
548
+ return
549
+ if job.event == "merge_group":
550
+ emit(
551
+ db,
552
+ merge_queue_failure(
553
+ job,
554
+ account_id,
555
+ f"CommitGuard could not validate the merge group ({reason}). The check "
556
+ "failed closed, so the merge queue cannot merge it.",
557
+ ),
558
+ now,
559
+ )
560
+ elif job.trigger is ScanTrigger.RERUN:
561
+ emit(
562
+ db,
563
+ NotificationEvent(
564
+ type=NotificationType.CHECK_RERUN_FAILED,
565
+ account_id=account_id,
566
+ severity=Severity.MEDIUM,
567
+ installation_id=job.installation_id,
568
+ repository_id=job.repository.id,
569
+ resource_type="scan",
570
+ resource_id=job.job_id,
571
+ dedup_key=domain_key(NotificationType.CHECK_RERUN_FAILED, job.job_id),
572
+ title=f"Check re-run failed in {job.repository.full_name}",
573
+ body=(
574
+ f"The re-run of {job.check_name} at {job.head_sha[:12]} could not be "
575
+ f"completed: {reason}. The check failed closed."
576
+ ),
577
+ metadata={"scan": job.job_id, "execution": job.execution},
578
+ ),
579
+ now,
580
+ )
581
+
582
+ def _exhausted(self, job: ScanJob) -> None:
583
+ """A job that crashed or timed out on every attempt: audit it and fail its check."""
584
+ reason = "scan abandoned after repeated attempts"
585
+ with correlation(
586
+ job_id=job.job_id,
587
+ installation_id=job.installation_id,
588
+ repository=job.repository.full_name,
589
+ ):
590
+ self._metrics.increment(SCANS_FAILED, kind=FailureKind.INTERNAL.value)
591
+ self._audit_job(
592
+ AuditEventType.SCAN_ERROR,
593
+ job,
594
+ failure_kind=FailureKind.INTERNAL.value,
595
+ reason=reason,
596
+ attempts=job.attempts,
597
+ )
598
+ with self._store.transaction() as db:
599
+ self._emit_failure_notifications(db, job, reason, self._now())
600
+ if job.check_run_id is None:
601
+ return
602
+ try:
603
+ auth = self._installations.authorize(job.installation_id, job.repository)
604
+ progress = _Progress(auth=auth, check_run_id=job.check_run_id)
605
+ conclusion, output = error_output(FailureKind.INTERNAL, reason)
606
+ self._publish(job, progress, CheckRunStatus.COMPLETED, output, conclusion)
607
+ except StaleScanError:
608
+ pass # a newer execution owns the check
609
+ except Exception as exc: # noqa: BLE001 - best effort; the job is already an error
610
+ log.error("check_run_failure_not_published", error_type=type(exc).__name__)
611
+
612
+ def _audit_job(
613
+ self,
614
+ event_type: AuditEventType,
615
+ job: ScanJob,
616
+ *,
617
+ action: Action | None = None,
618
+ **data: str | int | bool | None,
619
+ ) -> None:
620
+ self._audit.record(
621
+ event_type,
622
+ installation_id=job.installation_id,
623
+ repository_id=job.repository.id,
624
+ repository=job.repository.full_name,
625
+ head_sha=job.head_sha,
626
+ action=action,
627
+ extra=data,
628
+ )