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,909 @@
1
+ """Recording scan results and tracking whether each violation is still present.
2
+
3
+ Finding versus violation
4
+ ========================
5
+
6
+ * A **finding** row is one detector finding in one scan: immutable history.
7
+ Every finding of a completed scan is stored, including findings a disabled
8
+ policy allowed.
9
+ * A **violation** is a finding whose policy action was ``block`` or ``warn``,
10
+ tracked across scans by the finding fingerprint (detector, rule, commit SHA
11
+ and evidence) within one repository. The same attribution on the same commit
12
+ is one violation however often it is scanned.
13
+
14
+ Violation status
15
+ ================
16
+
17
+ A violation is *exposed* wherever CommitGuard saw it:
18
+
19
+ * **pull request exposure** - active while the newest completed scan of that
20
+ pull request still contains the finding. Pull request scans always cover the
21
+ complete ``base..head`` range, so absence means the commit left the pull
22
+ request (rewritten, removed, or the base now contains a fixed history). A
23
+ closed pull request ends the exposure; a merged one moves it to the base
24
+ branch.
25
+ * **merge group exposure** - active while the merge queue's temporary merge
26
+ group commit that contained the finding exists; it ends when GitHub destroys
27
+ the merge group (merged into the base branch: the exposure moves to that
28
+ branch; invalidated or dequeued: it ends).
29
+ * **branch exposure** - push scans are incremental (only new commits), so
30
+ absence proves nothing. The exposure ends when a later push scan of that
31
+ branch shows the commit is no longer reachable from the branch head (history
32
+ was rewritten), or the branch is deleted. When reachability cannot be
33
+ determined the exposure stays active: CommitGuard does not guess in the
34
+ permissive direction.
35
+
36
+ ``open`` at least one active exposure: the violation is currently present
37
+ in a pull request or branch CommitGuard monitors.
38
+ ``acknowledged`` open, and a security manager has recorded that they reviewed it.
39
+ Enforcement is unchanged: GitHub checks still fail.
40
+ ``resolved`` no active exposure remains. Set only by CommitGuard from scan and
41
+ GitHub events, never by a user. History is kept.
42
+
43
+ A violation that appears again after being resolved is reopened and its
44
+ acknowledgement cleared, so a new occurrence is reviewed again.
45
+
46
+ Stale results: if a newer scan of the same pull request or branch has already
47
+ completed, an older scan's findings are stored as history but do not change
48
+ exposures, so a slow scan cannot reopen or close anything behind a newer one.
49
+
50
+ Notifications: newly opened (or reopened) violations that a scan *blocked* with
51
+ severity ``high`` or ``critical`` produce one notification per rule per pull
52
+ request, branch or merge group - not one per commit - written to the
53
+ notification outbox in the same transaction as the violations. A blocked merge
54
+ group additionally produces a ``merge_queue_failure`` notification.
55
+ """
56
+
57
+ import json
58
+ import sqlite3
59
+ import uuid
60
+ from collections.abc import Callable, Iterable
61
+ from dataclasses import dataclass
62
+ from datetime import UTC, datetime
63
+ from typing import Any
64
+
65
+ from commitguard.audit.models import AuditEvent, AuditEventType
66
+ from commitguard.core.decision import Action
67
+ from commitguard.core.result import Severity
68
+ from commitguard.git.repository import Repository
69
+ from commitguard.github.pull_requests import branch_group_key, group_key, merge_group_key
70
+ from commitguard.github.storage import JobState, ScanJob, SqliteStateStore
71
+ from commitguard.notifications.deduplication import domain_key
72
+ from commitguard.notifications.models import NotificationEvent, NotificationType
73
+ from commitguard.notifications.outbox import account_for_installation, emit
74
+ from commitguard.observability.logging import get_logger
75
+ from commitguard.security.sanitization import sanitize_for_terminal
76
+ from commitguard.security.secrets import redact
77
+ from commitguard.services.audit import AuditService
78
+ from commitguard.services.reports import EvaluatedFinding
79
+ from commitguard.services.scan import ScanResult
80
+
81
+ log = get_logger(__name__)
82
+
83
+ MAX_IDENTITY_CHARS = 256
84
+ MAX_TEXT_CHARS = 1000
85
+ MAX_EVIDENCE_VALUE_CHARS = 512
86
+ MAX_IDENTITY_LOOKUPS = 500
87
+ TRACKED_ACTIONS = (Action.BLOCK, Action.WARN)
88
+ NOTIFY_SEVERITIES = {
89
+ Severity.CRITICAL: NotificationType.CRITICAL_VIOLATION,
90
+ Severity.HIGH: NotificationType.HIGH_VIOLATION,
91
+ }
92
+ MAX_NOTIFIED_VIOLATIONS = 20
93
+
94
+
95
+ def clean_text(value: str, limit: int = MAX_TEXT_CHARS) -> str:
96
+ """Untrusted text as stored for display: secrets redacted, control characters visible."""
97
+ return sanitize_for_terminal(redact(value), max_length=limit)
98
+
99
+
100
+ def _ts(value: datetime) -> float:
101
+ return value.timestamp()
102
+
103
+
104
+ @dataclass(frozen=True, slots=True)
105
+ class _Group:
106
+ key: str
107
+ kind: str # "pull_request" | "branch" | "merge_group"
108
+ label: str
109
+
110
+
111
+ def job_group(job: ScanJob) -> _Group:
112
+ if job.event == "merge_group":
113
+ base = (job.context.ref or "").removeprefix("refs/heads/")
114
+ return _Group(job.group_key, "merge_group", clean_text(f"merge queue ({base})", 256))
115
+ if job.pull_request_number is not None:
116
+ return _Group(
117
+ group_key(job.pull_request_number), "pull_request", f"#{job.pull_request_number}"
118
+ )
119
+ ref = job.context.ref or ""
120
+ return _Group(branch_group_key(ref), "branch", clean_text(ref, 256))
121
+
122
+
123
+ def _closed_reason(number: int) -> str:
124
+ return f"pull request #{number} was closed without merging"
125
+
126
+
127
+ def _evidence_document(item: EvaluatedFinding) -> str:
128
+ return json.dumps(
129
+ [
130
+ {
131
+ "source": evidence.source.value,
132
+ "source_label": evidence.source.label,
133
+ "value": clean_text(evidence.value, MAX_EVIDENCE_VALUE_CHARS),
134
+ "line_number": evidence.line_number,
135
+ "matched": [
136
+ {
137
+ "kind": reason.kind.value,
138
+ "value": clean_text(reason.value, 256),
139
+ "rule": clean_text(reason.rule, 256),
140
+ }
141
+ for reason in evidence.matched
142
+ ],
143
+ "notes": [clean_text(note, 256) for note in evidence.notes],
144
+ }
145
+ for evidence in item.finding.evidence
146
+ ],
147
+ ensure_ascii=True,
148
+ )
149
+
150
+
151
+ class ScanResultRecorder:
152
+ def __init__(
153
+ self,
154
+ store: SqliteStateStore,
155
+ audit: AuditService,
156
+ *,
157
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
158
+ ) -> None:
159
+ self._store = store
160
+ self._audit = audit
161
+ self._now = now
162
+
163
+ # ------------------------------------------------------------------ #
164
+ # Scan completion
165
+ # ------------------------------------------------------------------ #
166
+ def record_completed(
167
+ self,
168
+ job: ScanJob,
169
+ result: ScanResult,
170
+ *,
171
+ state: JobState,
172
+ conclusion: str,
173
+ repository: Repository | None,
174
+ organization_policy_version: int | None,
175
+ governance_record: str | None = None,
176
+ governance_fingerprint: str | None = None,
177
+ ) -> None:
178
+ """Store the result, its findings and the violation lifecycle in one transaction."""
179
+ now = self._now()
180
+ # Errors and cancellations never reach this method: they change no violation state.
181
+ findings = [f for commit in result.report.commits for f in commit.findings]
182
+ identities = self._identities(repository, {f.finding.commit_sha for f in findings})
183
+ group = job_group(job)
184
+ current = {f.fingerprint for f in findings if f.action in TRACKED_ACTIONS}
185
+ # Reachability checks run Git, so they happen before the database transaction.
186
+ unreachable = self._unreachable_branch_exposures(job, group, current, repository)
187
+
188
+ stats = result.statistics
189
+ metadata = result.metadata
190
+ notices = [*result.plan.notices]
191
+ # Monitor mode reports block as warn; alerts still go out for what would have blocked.
192
+ monitored = (
193
+ {r.policy_id for r in result.effective.rules if r.monitor_mode}
194
+ if result.effective is not None
195
+ else set()
196
+ )
197
+ events: list[AuditEvent] = []
198
+ with self._store.transaction() as db:
199
+ db.execute(
200
+ "UPDATE scan_jobs SET state = ?, scan_id = ?, result_action = ?, conclusion = ?, "
201
+ "commits_scanned = ?, violations = ?, warnings = ?, lease_expires_at = NULL, "
202
+ "base_sha = ?, completed_at = ?, tool_version = ?, rules_version = ?, "
203
+ "policy_version = ?, policy_source = ?, organization_policy_version = ?, "
204
+ "effective_policies = ?, findings_count = ?, detector_failures = ?, notices = ?, "
205
+ "governance = ?, governance_fingerprint = ?, repository_policies = ?, "
206
+ "updated_at = ? WHERE job_id = ?",
207
+ (
208
+ state.value,
209
+ metadata.scan_id,
210
+ result.action.value,
211
+ conclusion,
212
+ stats.commits_scanned,
213
+ stats.violations,
214
+ stats.warnings,
215
+ metadata.base_sha,
216
+ _ts(now),
217
+ metadata.tool_version,
218
+ metadata.rules_version,
219
+ metadata.policy_version,
220
+ clean_text(metadata.policy_source, 500),
221
+ organization_policy_version,
222
+ json.dumps(
223
+ [
224
+ {"id": p.id, "enabled": p.enabled, "action": p.action.value}
225
+ for p in result.policies
226
+ ]
227
+ ),
228
+ stats.findings,
229
+ stats.detector_failures,
230
+ json.dumps([clean_text(n, 1000) for n in notices[:20]]),
231
+ governance_record,
232
+ governance_fingerprint,
233
+ json.dumps(result.repository_overrides, sort_keys=True)
234
+ if result.effective is not None
235
+ else None,
236
+ _ts(now),
237
+ job.job_id,
238
+ ),
239
+ )
240
+ stale = self._newer_scan_completed(db, job)
241
+ touched: set[str] = set()
242
+ new_blocked: dict[tuple[NotificationType, str], list[str]] = {}
243
+ for item in findings:
244
+ violation_id = None
245
+ if item.action in TRACKED_ACTIONS:
246
+ violation_id, event = self._upsert_violation(
247
+ db, job, item, identities, now, stale=stale
248
+ )
249
+ touched.add(violation_id)
250
+ if event is not None:
251
+ events.append(event)
252
+ notification_type = NOTIFY_SEVERITIES.get(item.finding.severity)
253
+ would_block = item.action is Action.BLOCK or (
254
+ item.action is Action.WARN and item.finding.rule_id in monitored
255
+ )
256
+ if not stale and would_block and notification_type is not None:
257
+ key = (notification_type, item.finding.rule_id)
258
+ new_blocked.setdefault(key, []).append(violation_id)
259
+ if not stale:
260
+ self._activate_exposure(db, job, group, violation_id, now)
261
+ author, committer = identities.get(item.finding.commit_sha or "", (None, None))
262
+ self._insert_finding(db, job, item, violation_id, author, committer, now)
263
+ if not stale:
264
+ closed = self._close_absent_exposures(db, job, group, current, unreachable, now)
265
+ touched.update(closed)
266
+ events.extend(self._refresh_statuses(db, touched, now))
267
+ events = [self._store.insert_audit_event(db, e) for e in events]
268
+ account_id = account_for_installation(db, job.installation_id)
269
+ if account_id is not None:
270
+ aggregate = bool(new_blocked) and _aggregates_violations(db, account_id)
271
+ for (notification_type, rule_id), ids in sorted(new_blocked.items()):
272
+ notification = self._violation_notification(
273
+ job,
274
+ group,
275
+ account_id,
276
+ notification_type,
277
+ rule_id,
278
+ ids,
279
+ monitored=rule_id in monitored,
280
+ aggregated=aggregate,
281
+ )
282
+ emit(db, notification, now)
283
+ if aggregate:
284
+ emit(db, _violation_digest(db, notification, now), now)
285
+ if job.event == "merge_group" and state is JobState.FAILED and not stale:
286
+ emit(
287
+ db,
288
+ merge_queue_failure(
289
+ job,
290
+ account_id,
291
+ f"CommitGuard blocked the merge group: {stats.violations} "
292
+ "violation(s). The merge queue cannot merge it.",
293
+ ),
294
+ now,
295
+ )
296
+ for event in events:
297
+ self._audit.log_stored(event)
298
+
299
+ @staticmethod
300
+ def _violation_notification(
301
+ job: ScanJob,
302
+ group: _Group,
303
+ account_id: int,
304
+ notification_type: NotificationType,
305
+ rule_id: str,
306
+ violation_ids: list[str],
307
+ *,
308
+ monitored: bool = False,
309
+ aggregated: bool = False,
310
+ ) -> NotificationEvent:
311
+ severity = (
312
+ Severity.CRITICAL
313
+ if notification_type is NotificationType.CRITICAL_VIOLATION
314
+ else Severity.HIGH
315
+ )
316
+ where = {
317
+ "pull_request": f"pull request {group.label}",
318
+ "merge_group": group.label,
319
+ }.get(group.kind, f"branch {group.label}")
320
+ count = len(violation_ids)
321
+ return NotificationEvent(
322
+ type=notification_type,
323
+ account_id=account_id,
324
+ severity=severity,
325
+ installation_id=job.installation_id,
326
+ repository_id=job.repository.id,
327
+ resource_type="violation",
328
+ resource_id=violation_ids[0],
329
+ dedup_key=domain_key(
330
+ notification_type,
331
+ job.installation_id,
332
+ job.repository.id,
333
+ group.key,
334
+ rule_id,
335
+ ),
336
+ title=(
337
+ f"Would block (monitor mode): {rule_id} in {job.repository.full_name}"
338
+ if monitored
339
+ else f"Blocked: {rule_id} in {job.repository.full_name}"
340
+ ),
341
+ body=(
342
+ (
343
+ f"CommitGuard found {count} commit(s) in {where} of "
344
+ f"{job.repository.full_name} ({rule_id}, {severity.value}) at "
345
+ f"{job.head_sha[:12]} that policy blocks. The repository is in monitor mode, "
346
+ "so the GitHub check reports a warning instead of failing."
347
+ )
348
+ if monitored
349
+ else (
350
+ f"CommitGuard blocked {count} commit(s) in {where} of "
351
+ f"{job.repository.full_name} ({rule_id}, {severity.value}) at "
352
+ f"{job.head_sha[:12]}. The GitHub check failed; remediation is shown on the "
353
+ "violation page."
354
+ )
355
+ ),
356
+ metadata={
357
+ "rule": rule_id,
358
+ "violations": count,
359
+ "violation_ids": ",".join(violation_ids[:MAX_NOTIFIED_VIOLATIONS]),
360
+ "scan": job.job_id,
361
+ "head_sha": job.head_sha,
362
+ "source": group.kind,
363
+ "location": where,
364
+ "monitor_mode": monitored,
365
+ # Organization digest enabled: e-mail and webhooks go to the digest instead.
366
+ "aggregated": aggregated,
367
+ },
368
+ )
369
+
370
+ # ------------------------------------------------------------------ #
371
+ # Merge queue
372
+ # ------------------------------------------------------------------ #
373
+ def merge_group_destroyed(
374
+ self,
375
+ installation_id: int,
376
+ repository_id: int,
377
+ head_sha: str,
378
+ *,
379
+ reason: str | None,
380
+ base_ref: str,
381
+ ) -> None:
382
+ """End merge group exposures; a merged group's violations move to the base branch."""
383
+ now = self._now()
384
+ key = merge_group_key(head_sha)
385
+ merged = reason == "merged"
386
+ text = (
387
+ f"merge group {head_sha[:12]} was merged into {clean_text(base_ref, 200)}"
388
+ if merged
389
+ else f"merge group {head_sha[:12]} was {reason or 'removed'}"
390
+ )
391
+ events: list[AuditEvent] = []
392
+ with self._store.transaction() as db:
393
+ rows = db.execute(
394
+ "SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
395
+ "AND repository_id = ? AND group_key = ? AND active = 1",
396
+ (installation_id, repository_id, key),
397
+ ).fetchall()
398
+ ids = {row["violation_id"] for row in rows}
399
+ db.execute(
400
+ "UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ? "
401
+ "WHERE installation_id = ? AND repository_id = ? AND group_key = ? AND active = 1",
402
+ (_ts(now), text, installation_id, repository_id, key),
403
+ )
404
+ if merged:
405
+ branch = _Group(branch_group_key(base_ref), "branch", clean_text(base_ref, 256))
406
+ for violation_id in ids:
407
+ self._activate_exposure_row(
408
+ db, installation_id, repository_id, branch, violation_id, None, now
409
+ )
410
+ events.extend(self._refresh_statuses(db, ids, now))
411
+ events = [self._store.insert_audit_event(db, e) for e in events]
412
+ for event in events:
413
+ self._audit.log_stored(event)
414
+
415
+ # ------------------------------------------------------------------ #
416
+ # GitHub lifecycle events
417
+ # ------------------------------------------------------------------ #
418
+ def pull_request_closed(
419
+ self,
420
+ installation_id: int,
421
+ repository_id: int,
422
+ number: int,
423
+ *,
424
+ merged: bool,
425
+ base_ref: str | None,
426
+ ) -> None:
427
+ now = self._now()
428
+ key = group_key(number)
429
+ reason = (
430
+ f"pull request #{number} was merged into {clean_text(base_ref or 'its base', 200)}"
431
+ if merged
432
+ else _closed_reason(number)
433
+ )
434
+ events: list[AuditEvent] = []
435
+ with self._store.transaction() as db:
436
+ rows = db.execute(
437
+ "SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
438
+ "AND repository_id = ? AND group_key = ? AND active = 1",
439
+ (installation_id, repository_id, key),
440
+ ).fetchall()
441
+ ids = {row["violation_id"] for row in rows}
442
+ db.execute(
443
+ "UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ? "
444
+ "WHERE installation_id = ? AND repository_id = ? AND group_key = ? AND active = 1",
445
+ (_ts(now), reason, installation_id, repository_id, key),
446
+ )
447
+ if merged and base_ref:
448
+ branch = _Group(branch_group_key(base_ref), "branch", clean_text(base_ref, 256))
449
+ for violation_id in ids:
450
+ self._activate_exposure_row(
451
+ db, installation_id, repository_id, branch, violation_id, None, now
452
+ )
453
+ events.extend(self._refresh_statuses(db, ids, now))
454
+ events = [self._store.insert_audit_event(db, e) for e in events]
455
+ for event in events:
456
+ self._audit.log_stored(event)
457
+
458
+ def pull_request_reopened(self, installation_id: int, repository_id: int, number: int) -> None:
459
+ """Restore exposures ended by closing a pull request whose commits did not change."""
460
+ now = self._now()
461
+ key = group_key(number)
462
+ events: list[AuditEvent] = []
463
+ with self._store.transaction() as db:
464
+ rows = db.execute(
465
+ "SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
466
+ "AND repository_id = ? AND group_key = ? AND active = 0 AND closed_reason = ?",
467
+ (installation_id, repository_id, key, _closed_reason(number)),
468
+ ).fetchall()
469
+ ids = {row["violation_id"] for row in rows}
470
+ db.execute(
471
+ "UPDATE violation_exposures SET active = 1, closed_at = NULL, "
472
+ "closed_reason = NULL, opened_at = ? WHERE installation_id = ? "
473
+ "AND repository_id = ? AND group_key = ? AND active = 0 AND closed_reason = ?",
474
+ (_ts(now), installation_id, repository_id, key, _closed_reason(number)),
475
+ )
476
+ events.extend(self._refresh_statuses(db, ids, now))
477
+ events = [self._store.insert_audit_event(db, e) for e in events]
478
+ for event in events:
479
+ self._audit.log_stored(event)
480
+
481
+ def branch_deleted(self, installation_id: int, repository_id: int, ref: str) -> None:
482
+ now = self._now()
483
+ key = branch_group_key(ref)
484
+ reason = f"branch {clean_text(ref, 200)} was deleted"
485
+ events: list[AuditEvent] = []
486
+ with self._store.transaction() as db:
487
+ rows = db.execute(
488
+ "SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
489
+ "AND repository_id = ? AND group_key = ? AND active = 1",
490
+ (installation_id, repository_id, key),
491
+ ).fetchall()
492
+ ids = {row["violation_id"] for row in rows}
493
+ db.execute(
494
+ "UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ? "
495
+ "WHERE installation_id = ? AND repository_id = ? AND group_key = ? AND active = 1",
496
+ (_ts(now), reason, installation_id, repository_id, key),
497
+ )
498
+ events.extend(self._refresh_statuses(db, ids, now))
499
+ events = [self._store.insert_audit_event(db, e) for e in events]
500
+ for event in events:
501
+ self._audit.log_stored(event)
502
+
503
+ # ------------------------------------------------------------------ #
504
+ # Internals
505
+ # ------------------------------------------------------------------ #
506
+ @staticmethod
507
+ def _identities(
508
+ repository: Repository | None, shas: Iterable[str | None]
509
+ ) -> dict[str, tuple[str, str]]:
510
+ wanted = sorted(s for s in shas if s)[:MAX_IDENTITY_LOOKUPS]
511
+ if repository is None or not wanted:
512
+ return {}
513
+ identities: dict[str, tuple[str, str]] = {}
514
+ try:
515
+ for commit in repository.iter_commits(wanted):
516
+ if commit.sha:
517
+ identities[commit.sha] = (
518
+ clean_text(str(commit.author), MAX_IDENTITY_CHARS),
519
+ clean_text(str(commit.committer), MAX_IDENTITY_CHARS),
520
+ )
521
+ except Exception as exc: # noqa: BLE001 - identities are display data only
522
+ log.warning("finding_identities_unavailable", error_type=type(exc).__name__)
523
+ return identities
524
+
525
+ def _unreachable_branch_exposures(
526
+ self,
527
+ job: ScanJob,
528
+ group: _Group,
529
+ current: set[str],
530
+ repository: Repository | None,
531
+ ) -> set[str]:
532
+ """Active branch exposures whose commit is provably no longer on the branch."""
533
+ if group.kind != "branch" or repository is None:
534
+ return set()
535
+ rows = self._store.query(
536
+ "SELECT e.violation_id, v.fingerprint, v.commit_sha FROM violation_exposures e "
537
+ "JOIN violations v ON v.violation_id = e.violation_id WHERE e.installation_id = ? "
538
+ "AND e.repository_id = ? AND e.group_key = ? AND e.active = 1 LIMIT 1000",
539
+ (job.installation_id, job.repository.id, group.key),
540
+ )
541
+ gone: set[str] = set()
542
+ for row in rows:
543
+ if row["fingerprint"] in current or not row["commit_sha"]:
544
+ continue
545
+ try:
546
+ reachable = repository.is_ancestor(row["commit_sha"], job.head_sha)
547
+ except Exception as exc: # noqa: BLE001 - unknown keeps the exposure open
548
+ log.warning("reachability_check_failed", error_type=type(exc).__name__)
549
+ reachable = None
550
+ if reachable is False:
551
+ gone.add(row["violation_id"])
552
+ return gone
553
+
554
+ @staticmethod
555
+ def _newer_scan_completed(db: sqlite3.Connection, job: ScanJob) -> bool:
556
+ row = db.execute(
557
+ "SELECT 1 FROM scan_jobs WHERE installation_id = ? AND repository_id = ? "
558
+ "AND group_key = ? AND sequence > ? AND state IN ('passed', 'failed') LIMIT 1",
559
+ (job.installation_id, job.repository.id, job.group_key, job.sequence),
560
+ ).fetchone()
561
+ return row is not None
562
+
563
+ def _upsert_violation(
564
+ self,
565
+ db: sqlite3.Connection,
566
+ job: ScanJob,
567
+ item: EvaluatedFinding,
568
+ identities: dict[str, tuple[str, str]],
569
+ now: datetime,
570
+ *,
571
+ stale: bool,
572
+ ) -> tuple[str, AuditEvent | None]:
573
+ finding = item.finding
574
+ author = identities.get(finding.commit_sha or "", (None, None))[0]
575
+ row = db.execute(
576
+ "SELECT violation_id, status FROM violations WHERE installation_id = ? "
577
+ "AND repository_id = ? AND fingerprint = ?",
578
+ (job.installation_id, job.repository.id, item.fingerprint),
579
+ ).fetchone()
580
+ common: dict[str, Any] = {
581
+ "installation_id": job.installation_id,
582
+ "repository_id": job.repository.id,
583
+ "repository": job.repository.full_name,
584
+ "head_sha": job.head_sha,
585
+ "action": item.action,
586
+ }
587
+ if row is None:
588
+ violation_id = uuid.uuid4().hex
589
+ db.execute(
590
+ "INSERT INTO violations (violation_id, installation_id, repository_id, "
591
+ "fingerprint, "
592
+ "rule_id, detector, severity, severity_rank, action, title, commit_sha, author, "
593
+ "status, first_detected_at, last_detected_at, first_job_id, last_job_id, "
594
+ "detections, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, "
595
+ "?, ?, 1, ?)",
596
+ (
597
+ violation_id,
598
+ job.installation_id,
599
+ job.repository.id,
600
+ item.fingerprint,
601
+ finding.rule_id,
602
+ finding.detector,
603
+ finding.severity.value,
604
+ finding.severity.rank,
605
+ item.action.value,
606
+ clean_text(finding.title, 200),
607
+ finding.commit_sha,
608
+ author,
609
+ _ts(now),
610
+ _ts(now),
611
+ job.job_id,
612
+ job.job_id,
613
+ _ts(now),
614
+ ),
615
+ )
616
+ return violation_id, self._audit.build(
617
+ AuditEventType.VIOLATION_OPENED,
618
+ **common,
619
+ violation=violation_id,
620
+ rule=finding.rule_id,
621
+ severity=finding.severity.value,
622
+ )
623
+ violation_id = row["violation_id"]
624
+ reopen = row["status"] == "resolved" and not stale
625
+ db.execute(
626
+ "UPDATE violations SET last_detected_at = ?, last_job_id = ?, "
627
+ "detections = detections + 1, action = ?, severity = ?, severity_rank = ?, "
628
+ "title = ?, author = COALESCE(?, author), updated_at = ? WHERE violation_id = ?",
629
+ (
630
+ _ts(now),
631
+ job.job_id,
632
+ item.action.value,
633
+ finding.severity.value,
634
+ finding.severity.rank,
635
+ clean_text(finding.title, 200),
636
+ author,
637
+ _ts(now),
638
+ violation_id,
639
+ ),
640
+ )
641
+ if not reopen:
642
+ return violation_id, None
643
+ db.execute(
644
+ "UPDATE violations SET status = 'open', resolved_at = NULL, resolution = NULL, "
645
+ "acknowledged_at = NULL, acknowledged_by_id = NULL, acknowledged_by_login = NULL, "
646
+ "acknowledgement_note = NULL WHERE violation_id = ?",
647
+ (violation_id,),
648
+ )
649
+ return violation_id, self._audit.build(
650
+ AuditEventType.VIOLATION_REOPENED,
651
+ **common,
652
+ violation=violation_id,
653
+ rule=finding.rule_id,
654
+ )
655
+
656
+ def _activate_exposure(
657
+ self,
658
+ db: sqlite3.Connection,
659
+ job: ScanJob,
660
+ group: _Group,
661
+ violation_id: str,
662
+ now: datetime,
663
+ ) -> None:
664
+ self._activate_exposure_row(
665
+ db, job.installation_id, job.repository.id, group, violation_id, job.job_id, now
666
+ )
667
+
668
+ @staticmethod
669
+ def _activate_exposure_row(
670
+ db: sqlite3.Connection,
671
+ installation_id: int,
672
+ repository_id: int,
673
+ group: _Group,
674
+ violation_id: str,
675
+ job_id: str | None,
676
+ now: datetime,
677
+ ) -> None:
678
+ db.execute(
679
+ "INSERT INTO violation_exposures (violation_id, installation_id, repository_id, "
680
+ "group_key, kind, label, active, first_job_id, last_job_id, opened_at) "
681
+ "VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT (violation_id, group_key) DO UPDATE "
682
+ "SET active = 1, last_job_id = COALESCE(excluded.last_job_id, last_job_id), "
683
+ "opened_at = CASE WHEN active = 1 THEN opened_at ELSE excluded.opened_at END, "
684
+ "closed_at = NULL, closed_reason = NULL",
685
+ (
686
+ violation_id,
687
+ installation_id,
688
+ repository_id,
689
+ group.key,
690
+ group.kind,
691
+ group.label,
692
+ job_id,
693
+ job_id,
694
+ _ts(now),
695
+ ),
696
+ )
697
+
698
+ @staticmethod
699
+ def _close_absent_exposures(
700
+ db: sqlite3.Connection,
701
+ job: ScanJob,
702
+ group: _Group,
703
+ current: set[str],
704
+ unreachable: set[str],
705
+ now: datetime,
706
+ ) -> set[str]:
707
+ rows = db.execute(
708
+ "SELECT e.violation_id, v.fingerprint FROM violation_exposures e JOIN violations v "
709
+ "ON v.violation_id = e.violation_id WHERE e.installation_id = ? "
710
+ "AND e.repository_id = ? AND e.group_key = ? AND e.active = 1",
711
+ (job.installation_id, job.repository.id, group.key),
712
+ ).fetchall()
713
+ closed: set[str] = set()
714
+ for row in rows:
715
+ if row["fingerprint"] in current:
716
+ continue
717
+ if group.kind == "pull_request":
718
+ reason = (
719
+ f"no longer part of pull request {group.label} (scan of {job.head_sha[:12]})"
720
+ )
721
+ elif row["violation_id"] in unreachable:
722
+ reason = (
723
+ f"commit no longer reachable from {group.label} at {job.head_sha[:12]} "
724
+ "(history rewritten)"
725
+ )
726
+ else:
727
+ continue
728
+ db.execute(
729
+ "UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ?, "
730
+ "last_job_id = ? WHERE violation_id = ? AND group_key = ?",
731
+ (_ts(now), reason, job.job_id, row["violation_id"], group.key),
732
+ )
733
+ closed.add(row["violation_id"])
734
+ return closed
735
+
736
+ def _refresh_statuses(
737
+ self, db: sqlite3.Connection, violation_ids: Iterable[str], now: datetime
738
+ ) -> list[AuditEvent]:
739
+ events: list[AuditEvent] = []
740
+ for violation_id in sorted(violation_ids):
741
+ row = db.execute(
742
+ "SELECT v.status, v.installation_id, v.repository_id, v.rule_id, v.action, "
743
+ "(SELECT COUNT(*) FROM violation_exposures e WHERE e.violation_id = v.violation_id "
744
+ "AND e.active = 1) AS active, (SELECT closed_reason FROM violation_exposures e "
745
+ "WHERE e.violation_id = v.violation_id AND e.active = 0 ORDER BY closed_at DESC "
746
+ "LIMIT 1) AS reason FROM violations v WHERE v.violation_id = ?",
747
+ (violation_id,),
748
+ ).fetchone()
749
+ if row is None:
750
+ continue
751
+ if row["active"] == 0 and row["status"] == "open":
752
+ resolution = row["reason"] or "not present in a newer scan"
753
+ db.execute(
754
+ "UPDATE violations SET status = 'resolved', resolved_at = ?, resolution = ?, "
755
+ "updated_at = ? WHERE violation_id = ?",
756
+ (_ts(now), resolution, _ts(now), violation_id),
757
+ )
758
+ events.append(
759
+ self._audit.build(
760
+ AuditEventType.VIOLATION_RESOLVED,
761
+ installation_id=row["installation_id"],
762
+ repository_id=row["repository_id"],
763
+ action=Action(row["action"]),
764
+ violation=violation_id,
765
+ rule=row["rule_id"],
766
+ reason=resolution,
767
+ )
768
+ )
769
+ elif row["active"] > 0 and row["status"] == "resolved":
770
+ db.execute(
771
+ "UPDATE violations SET status = 'open', resolved_at = NULL, resolution = NULL, "
772
+ "acknowledged_at = NULL, acknowledged_by_id = NULL, "
773
+ "acknowledged_by_login = NULL, acknowledgement_note = NULL, updated_at = ? "
774
+ "WHERE violation_id = ?",
775
+ (_ts(now), violation_id),
776
+ )
777
+ events.append(
778
+ self._audit.build(
779
+ AuditEventType.VIOLATION_REOPENED,
780
+ installation_id=row["installation_id"],
781
+ repository_id=row["repository_id"],
782
+ action=Action(row["action"]),
783
+ violation=violation_id,
784
+ rule=row["rule_id"],
785
+ )
786
+ )
787
+ return events
788
+
789
+ @staticmethod
790
+ def _insert_finding(
791
+ db: sqlite3.Connection,
792
+ job: ScanJob,
793
+ item: EvaluatedFinding,
794
+ violation_id: str | None,
795
+ author: str | None,
796
+ committer: str | None,
797
+ now: datetime,
798
+ ) -> None:
799
+ finding = item.finding
800
+ db.execute(
801
+ "INSERT INTO findings (job_id, installation_id, repository_id, violation_id, "
802
+ "fingerprint, commit_sha, rule_id, detector, severity, severity_rank, confidence, "
803
+ "action, policy_id, reason, title, message, remediation, evidence, author, committer, "
804
+ "created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
805
+ (
806
+ job.job_id,
807
+ job.installation_id,
808
+ job.repository.id,
809
+ violation_id,
810
+ item.fingerprint,
811
+ finding.commit_sha,
812
+ finding.rule_id,
813
+ finding.detector,
814
+ finding.severity.value,
815
+ finding.severity.rank,
816
+ finding.confidence.value,
817
+ item.action.value,
818
+ item.policy_id,
819
+ clean_text(item.reason, 500),
820
+ clean_text(finding.title, 200),
821
+ clean_text(finding.message, MAX_TEXT_CHARS),
822
+ clean_text(finding.remediation, MAX_TEXT_CHARS),
823
+ _evidence_document(item),
824
+ author,
825
+ committer,
826
+ _ts(now),
827
+ ),
828
+ )
829
+
830
+
831
+ def _aggregates_violations(db: sqlite3.Connection, account_id: int) -> bool:
832
+ row = db.execute(
833
+ "SELECT document FROM organization_settings WHERE account_id = ?", (account_id,)
834
+ ).fetchone()
835
+ if row is None:
836
+ return False
837
+ try:
838
+ return bool(json.loads(str(row["document"])).get("aggregate_violation_alerts", False))
839
+ except ValueError:
840
+ return False
841
+
842
+
843
+ def _violation_digest(
844
+ db: sqlite3.Connection, event: NotificationEvent, now: datetime
845
+ ) -> NotificationEvent:
846
+ """One organization-level alert per rule and hour, however many repositories."""
847
+ rule = str(event.metadata.get("rule", ""))
848
+ window = NotificationType.VIOLATION_DIGEST
849
+ since = now.timestamp() - (now.timestamp() % 3600)
850
+ repositories = db.execute(
851
+ "SELECT COUNT(DISTINCT repository_id) AS n FROM notification_events WHERE account_id = ? "
852
+ "AND type IN ('critical_violation', 'high_violation') AND last_occurred_at >= ? "
853
+ "AND json_extract(metadata, '$.rule') = ?",
854
+ (event.account_id, since, rule),
855
+ ).fetchone()["n"]
856
+ count = max(int(repositories or 0), 1)
857
+ return NotificationEvent(
858
+ type=window,
859
+ account_id=event.account_id,
860
+ severity=event.severity,
861
+ resource_type="organization",
862
+ resource_id=str(event.account_id),
863
+ dedup_key=domain_key(window, event.account_id, rule),
864
+ title=f"CommitGuard detected blocked violations of {rule} in {count} repositories",
865
+ body=(
866
+ f"Violations of {rule} ({event.severity.value}) were detected in {count} "
867
+ "repository(ies) of your organization in the last hour. E-mail and webhooks are "
868
+ "aggregated; the dashboard lists every repository and violation."
869
+ ),
870
+ metadata={"rule": rule, "repositories": count},
871
+ )
872
+
873
+
874
+ def merge_queue_failure(job: ScanJob, account_id: int, body: str) -> NotificationEvent:
875
+ return NotificationEvent(
876
+ type=NotificationType.MERGE_QUEUE_FAILURE,
877
+ account_id=account_id,
878
+ severity=Severity.HIGH,
879
+ installation_id=job.installation_id,
880
+ repository_id=job.repository.id,
881
+ resource_type="scan",
882
+ resource_id=job.job_id,
883
+ dedup_key=domain_key(
884
+ NotificationType.MERGE_QUEUE_FAILURE,
885
+ job.installation_id,
886
+ job.repository.id,
887
+ job.head_sha,
888
+ ),
889
+ title=f"Merge queue blocked in {job.repository.full_name}",
890
+ body=f"{body} Merge group commit {job.head_sha[:12]}.",
891
+ metadata={"scan": job.job_id, "head_sha": job.head_sha, "source": "merge_queue"},
892
+ )
893
+
894
+
895
+ def scan_result_label(
896
+ state: str, result_action: str | None, failure_kind: str | None = None
897
+ ) -> str:
898
+ """The dashboard's scan result for a stored job state (see ``views.ScanResultStatus``)."""
899
+ if state == JobState.CANCELLED.value and failure_kind == "stale":
900
+ return "stale"
901
+ if state == JobState.PASSED.value:
902
+ return "warning" if result_action == Action.WARN.value else "pass"
903
+ return {
904
+ JobState.QUEUED.value: "queued",
905
+ JobState.RUNNING.value: "running",
906
+ JobState.FAILED.value: "blocked",
907
+ JobState.ERROR.value: "error",
908
+ JobState.CANCELLED.value: "cancelled",
909
+ }.get(state, "error")