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,449 @@
1
+ """``commitguard github``: GitHub Actions guidance and GitHub App operations.
2
+
3
+ * ``setup`` - local, read-only Actions workflow guidance (no API access);
4
+ * ``validate`` - check GitHub App configuration and, unless ``--offline``,
5
+ authenticate to GitHub and verify permissions and installations;
6
+ * ``webhook-test`` - verify and normalise a webhook payload locally (no network,
7
+ no scan, no repository code);
8
+ * ``serve`` - run the GitHub App webhook service (development server).
9
+
10
+ No command configures or verifies branch protection, and no command prints the
11
+ private key, the webhook secret, JWTs or installation tokens.
12
+ """
13
+
14
+ import os
15
+ from collections.abc import Callable
16
+ from pathlib import Path
17
+ from typing import TYPE_CHECKING, Annotated
18
+
19
+ import typer
20
+
21
+ from commitguard.cli.output import ExitCode, handled_errors, info, supports_unicode
22
+ from commitguard.config.sources import load_mandatory_policy
23
+ from commitguard.exceptions.base import CommitGuardError
24
+ from commitguard.git.repository import Repository
25
+ from commitguard.github.errors import WebhookValidationError, safe_text
26
+ from commitguard.github.events import (
27
+ CheckRunRerequestedEvent,
28
+ CheckSuiteRerequestedEvent,
29
+ IgnoredEvent,
30
+ InstallationEvent,
31
+ InstallationRepositoriesEvent,
32
+ MergeGroupEvent,
33
+ PullRequestEvent,
34
+ PushEvent,
35
+ normalize_webhook,
36
+ )
37
+ from commitguard.github.permissions import (
38
+ OPTIONAL_PERMISSIONS,
39
+ OPTIONAL_WEBHOOK_EVENTS,
40
+ REQUIRED_PERMISSIONS,
41
+ WEBHOOK_EVENTS,
42
+ excessive_permissions,
43
+ missing_permissions,
44
+ )
45
+ from commitguard.github.pull_requests import disposition
46
+ from commitguard.github.settings import (
47
+ ENV_DATA_DIR,
48
+ ENV_MANDATORY_POLICY_FILE,
49
+ private_key_file_too_open,
50
+ read_app_id,
51
+ read_private_key,
52
+ read_webhook_secret,
53
+ )
54
+ from commitguard.github.webhooks import MAX_WEBHOOK_BYTES, parse_json_object, verify_signature
55
+ from commitguard.github.workflow import (
56
+ CHECK_NAME,
57
+ WORKFLOW_FILE,
58
+ WorkflowIssueLevel,
59
+ inspect_repository_workflows,
60
+ )
61
+ from commitguard.observability.logging import configure_json_logging
62
+ from commitguard.security.sanitization import sanitize_for_terminal
63
+ from commitguard.utils.filesystem import read_bytes_limited
64
+
65
+ if TYPE_CHECKING: # the App needs the optional 'app' extra (cryptography) and HTTP modules
66
+ from commitguard.github.auth import AppCredentials
67
+ from commitguard.github.client import Transport
68
+
69
+ github_app = typer.Typer(
70
+ help="GitHub enforcement: Actions setup guidance and the CommitGuard GitHub App.",
71
+ no_args_is_help=True,
72
+ )
73
+
74
+ # Replaced in tests; None means HTTPS to api.github.com via the standard library.
75
+ transport_factory: "Callable[[], Transport] | None" = None
76
+
77
+
78
+ def _app_modules_available() -> None:
79
+ try:
80
+ import cryptography # noqa: F401
81
+ except ImportError:
82
+ raise CommitGuardError(
83
+ "the GitHub App needs the optional dependencies. CommitGuard is not on PyPI "
84
+ "(that name belongs to an unrelated project); install from source, e.g. "
85
+ 'python -m pip install "commitguard[app] @ '
86
+ 'git+https://github.com/oyinlola-tech/commitguard@<commit-sha>"'
87
+ ) from None
88
+
89
+
90
+ GUIDANCE = """\
91
+ To make the check authoritative (repository Settings -> Rules / Branches):
92
+
93
+ 1. Protect the target branch (e.g. main) with a ruleset or branch protection rule.
94
+ 2. Require a pull request before merging; do not allow bypassing, and
95
+ block direct pushes (restrict who can push / "Restrict updates").
96
+ 3. Require status checks to pass, and add the check "{check}".
97
+ GitHub lists it under that job name after the workflow has run once.
98
+ 4. If you use a merge queue, keep the merge_group trigger in the workflow.
99
+ 5. Protect the enforcement files: require code owner review for
100
+ .github/workflows/ and .commitguard.yaml (CODEOWNERS), because a pull
101
+ request can edit the workflow that checks it. Organisations can instead
102
+ require the workflow from a separate repository with a ruleset.
103
+
104
+ Without these settings the workflow only reports; it does not prevent merges.
105
+ A workflow triggered by push runs after the commits are already on GitHub.
106
+ CommitGuard cannot verify these settings locally."""
107
+
108
+
109
+ def setup_command() -> None:
110
+ """Show GitHub workflow status, the required check name and setup steps."""
111
+ ok, cross = ("✓", "✗") if supports_unicode() else ("OK", "X")
112
+ bang = "!"
113
+ symbol = {
114
+ WorkflowIssueLevel.OK: "-",
115
+ WorkflowIssueLevel.WARN: bang,
116
+ WorkflowIssueLevel.FAIL: cross,
117
+ }
118
+ with handled_errors():
119
+ repository = Repository.discover()
120
+ inspections = inspect_repository_workflows(repository.root)
121
+
122
+ info("CommitGuard GitHub setup")
123
+ info("")
124
+ if not inspections:
125
+ info(f"{cross} No workflow runs CommitGuard.")
126
+ info(
127
+ f" Create {WORKFLOW_FILE.as_posix()} with: commitguard init --github "
128
+ "--action-repository OWNER/REPO --action-ref <commit sha>"
129
+ )
130
+ raise typer.Exit(code=int(ExitCode.ERROR))
131
+ failed = False
132
+ for inspection in inspections:
133
+ path = inspection.path.relative_to(repository.root).as_posix()
134
+ info(f"{ok} {sanitize_for_terminal(path)}")
135
+ for name in inspection.check_names:
136
+ info(f" Required check name: {sanitize_for_terminal(name)}")
137
+ for issue in inspection.issues:
138
+ failed = failed or issue.level is WorkflowIssueLevel.FAIL
139
+ info(
140
+ f" {symbol[issue.level]} {sanitize_for_terminal(issue.message, max_length=300)}"
141
+ )
142
+ info("")
143
+ info(GUIDANCE.format(check=CHECK_NAME))
144
+ if failed:
145
+ raise typer.Exit(code=int(ExitCode.ERROR))
146
+
147
+
148
+ github_app.command("setup")(setup_command)
149
+
150
+
151
+ class _Checklist:
152
+ def __init__(self) -> None:
153
+ self.ok, self.cross = ("✓", "✗") if supports_unicode() else ("OK", "X")
154
+ self.bang = "!"
155
+ self.failed = False
156
+
157
+ def passed(self, label: str, detail: str = "") -> None:
158
+ info(f"{self.ok} {label}" + (f": {detail}" if detail else ""))
159
+
160
+ def fail(self, label: str, detail: str) -> None:
161
+ self.failed = True
162
+ info(f"{self.cross} {label}: {safe_text(detail, 400)}")
163
+
164
+ def warn(self, label: str, detail: str) -> None:
165
+ info(f"{self.bang} {label}: {safe_text(detail, 400)}")
166
+
167
+ def skip(self, label: str, detail: str) -> None:
168
+ info(f"- {label}: {detail}")
169
+
170
+
171
+ @github_app.command("validate")
172
+ def validate_command(
173
+ installation_id: Annotated[
174
+ int | None,
175
+ typer.Option("--installation-id", min=1, help="Also mint a token for this installation."),
176
+ ] = None,
177
+ offline: Annotated[
178
+ bool,
179
+ typer.Option("--offline", help="Check local configuration only; do not contact GitHub."),
180
+ ] = False,
181
+ ) -> None:
182
+ """Validate GitHub App configuration, authentication, installations and permissions."""
183
+ env = os.environ
184
+ check = _Checklist()
185
+ info("CommitGuard GitHub Configuration")
186
+ info("")
187
+
188
+ app_id = None
189
+ try:
190
+ app_id = read_app_id(env)
191
+ check.passed("App ID")
192
+ except CommitGuardError as exc:
193
+ check.fail("App ID", str(exc))
194
+
195
+ credentials = None
196
+ try:
197
+ _app_modules_available()
198
+ from commitguard.github.auth import AppCredentials
199
+
200
+ credentials = AppCredentials(app_id or 1, read_private_key(env))
201
+ check.passed("Private key")
202
+ if private_key_file_too_open(env):
203
+ check.warn("Private key", "the key file is readable by other users (chmod 600)")
204
+ except CommitGuardError as exc:
205
+ check.fail("Private key", str(exc))
206
+
207
+ try:
208
+ read_webhook_secret(env)
209
+ check.passed("Webhook secret")
210
+ except CommitGuardError as exc:
211
+ check.fail("Webhook secret", str(exc))
212
+
213
+ try:
214
+ from commitguard.github.repositories import require_mirror_git
215
+
216
+ require_mirror_git()
217
+ check.passed("Git version")
218
+ except CommitGuardError as exc:
219
+ check.fail("Git version", str(exc))
220
+
221
+ data_dir = env.get(ENV_DATA_DIR, "")
222
+ if data_dir and Path(data_dir).is_absolute():
223
+ check.passed("Data directory")
224
+ else:
225
+ check.fail("Data directory", f"{ENV_DATA_DIR} must be set to an absolute path")
226
+
227
+ policy_file = env.get(ENV_MANDATORY_POLICY_FILE)
228
+ if policy_file:
229
+ try:
230
+ load_mandatory_policy(Path(policy_file))
231
+ check.passed("Mandatory policy")
232
+ except CommitGuardError as exc:
233
+ check.fail("Mandatory policy", str(exc))
234
+
235
+ if offline:
236
+ check.skip("GitHub authentication", "skipped (--offline)")
237
+ info("")
238
+ info("Status:")
239
+ info("NOT READY" if check.failed else "CONFIGURATION VALID (GitHub not contacted)")
240
+ raise typer.Exit(code=int(ExitCode.ERROR if check.failed else ExitCode.OK))
241
+
242
+ if credentials is None or app_id is None:
243
+ check.skip("GitHub authentication", "not attempted (fix the configuration above)")
244
+ else:
245
+ _validate_online(check, credentials, installation_id)
246
+
247
+ info("")
248
+ info("Status:")
249
+ info("NOT READY" if check.failed else "READY")
250
+ raise typer.Exit(code=int(ExitCode.ERROR if check.failed else ExitCode.OK))
251
+
252
+
253
+ def _validate_online(
254
+ check: _Checklist, credentials: "AppCredentials", installation_id: int | None
255
+ ) -> None:
256
+ from commitguard.github.auth import InstallationTokenProvider
257
+ from commitguard.github.client import GitHubClient
258
+
259
+ client = GitHubClient(transport_factory() if transport_factory else None)
260
+ try:
261
+ app = client.get_app(credentials.create_jwt())
262
+ check.passed("GitHub authentication", f"App {safe_text(app.slug, 80)}")
263
+ except CommitGuardError as exc:
264
+ check.fail("GitHub authentication", str(exc))
265
+ return
266
+
267
+ missing = missing_permissions(app.permissions)
268
+ if missing:
269
+ check.fail(
270
+ "Required permissions",
271
+ "missing " + ", ".join(f"{k}: {v}" for k, v in missing.items()),
272
+ )
273
+ else:
274
+ check.passed(
275
+ "Required permissions", ", ".join(f"{k}: {v}" for k, v in REQUIRED_PERMISSIONS.items())
276
+ )
277
+ extra = excessive_permissions(app.permissions)
278
+ if extra:
279
+ check.warn(
280
+ "Least privilege",
281
+ "not needed by CommitGuard: " + ", ".join(f"{k}: {v}" for k, v in extra.items()),
282
+ )
283
+ always_sent = {"installation", "installation_repositories"}
284
+ missing_events = sorted(set(WEBHOOK_EVENTS) - set(app.events) - always_sent)
285
+ if missing_events:
286
+ # GitHub always delivers installation events to Apps; they are not listed.
287
+ check.fail("Webhook events", "not subscribed: " + ", ".join(missing_events))
288
+ else:
289
+ check.passed("Webhook events")
290
+ optional_events = sorted(set(OPTIONAL_WEBHOOK_EVENTS) - set(app.events))
291
+ optional_permissions = missing_permissions(app.permissions, OPTIONAL_PERMISSIONS)
292
+ if optional_events or optional_permissions:
293
+ details = []
294
+ if optional_events:
295
+ details.append("not subscribed: " + ", ".join(optional_events))
296
+ if optional_permissions:
297
+ details.append(
298
+ "missing " + ", ".join(f"{k}: {v}" for k, v in optional_permissions.items())
299
+ )
300
+ check.warn(
301
+ "Re-runs and merge queue",
302
+ "; ".join(details) + " (GitHub re-run requests and merge queue validation are off)",
303
+ )
304
+
305
+ try:
306
+ installations = client.list_app_installations(credentials.create_jwt())
307
+ except CommitGuardError as exc:
308
+ check.fail("Installation access", str(exc))
309
+ return
310
+ if not installations:
311
+ check.fail("Installation access", "the App is not installed on any account")
312
+ return
313
+ selected = [i for i in installations if installation_id in (None, i.id)]
314
+ if installation_id is not None and not selected:
315
+ check.fail("Installation access", f"installation {installation_id} not found")
316
+ return
317
+ for item in selected:
318
+ suspended = " (suspended)" if item.suspended_at else ""
319
+ label = (
320
+ f"installation {item.id} ({item.account.type.value} {item.account.login}){suspended}"
321
+ )
322
+ installation_missing = missing_permissions(item.permissions)
323
+ if installation_missing:
324
+ check.fail(
325
+ "Installation permissions",
326
+ f"{label} has not granted " + ", ".join(installation_missing),
327
+ )
328
+ if installation_id is None:
329
+ check.passed("Installation access", f"{len(installations)} installation(s)")
330
+ return
331
+ try:
332
+ provider = InstallationTokenProvider(credentials, client)
333
+ token = provider.token(installation_id, None)
334
+ repositories = client.list_installation_repositories(token.token)
335
+ check.passed(
336
+ "Installation access",
337
+ f"installation {installation_id}: {len(repositories)} repository(ies) accessible",
338
+ )
339
+ provider.invalidate(installation_id)
340
+ except CommitGuardError as exc:
341
+ check.fail("Installation access", str(exc))
342
+
343
+
344
+ @github_app.command("webhook-test")
345
+ def webhook_test_command(
346
+ payload: Annotated[Path, typer.Argument(help="Webhook payload JSON file.", dir_okay=False)],
347
+ event: Annotated[str, typer.Option("--event", help="X-GitHub-Event value, e.g. push.")],
348
+ signature: Annotated[
349
+ str | None,
350
+ typer.Option(
351
+ "--signature",
352
+ help="X-Hub-Signature-256 value to verify with COMMITGUARD_GITHUB_WEBHOOK_SECRET.",
353
+ ),
354
+ ] = None,
355
+ ) -> None:
356
+ """Verify and normalise a webhook payload locally (no network, no scan)."""
357
+ check = _Checklist()
358
+ info("CommitGuard webhook test")
359
+ info("")
360
+ try:
361
+ body = read_bytes_limited(payload, max_bytes=MAX_WEBHOOK_BYTES)
362
+ except (OSError, CommitGuardError) as exc:
363
+ check.fail("Payload", str(exc) if isinstance(exc, CommitGuardError) else "cannot be read")
364
+ raise typer.Exit(code=int(ExitCode.ERROR)) from None
365
+ if signature is None:
366
+ check.skip("Signature", "not checked (pass --signature to verify)")
367
+ else:
368
+ try:
369
+ verify_signature(read_webhook_secret(os.environ), body, signature)
370
+ check.passed("Signature")
371
+ except CommitGuardError as exc:
372
+ check.fail("Signature", str(exc))
373
+ try:
374
+ normalized = normalize_webhook(event, parse_json_object(body))
375
+ check.passed("Payload", f"{event} event is well-formed")
376
+ except WebhookValidationError as exc:
377
+ check.fail("Payload", str(exc))
378
+ raise typer.Exit(code=int(ExitCode.ERROR)) from None
379
+
380
+ if isinstance(normalized, IgnoredEvent):
381
+ info(f" Result: ignored ({safe_text(normalized.reason, 100)})")
382
+ elif isinstance(normalized, InstallationEvent):
383
+ info(f" Installation: {normalized.installation_id} ({normalized.account.login})")
384
+ info(f" Action: {normalized.action.value}")
385
+ elif isinstance(normalized, InstallationRepositoriesEvent):
386
+ info(f" Installation: {normalized.installation_id} ({normalized.account.login})")
387
+ info(f" Repositories added: {len(normalized.added)}, removed: {len(normalized.removed)}")
388
+ elif isinstance(normalized, CheckRunRerequestedEvent | CheckSuiteRerequestedEvent):
389
+ info(f" Installation: {normalized.installation_id}")
390
+ info(f" Repository: {normalized.repository.full_name} (id {normalized.repository.id})")
391
+ info(f" Commit: {normalized.head_sha[:12]}")
392
+ info(" Result: re-run of the stored CommitGuard scan for this commit (if one exists)")
393
+ elif isinstance(normalized, MergeGroupEvent):
394
+ info(f" Installation: {normalized.installation_id}")
395
+ info(f" Repository: {normalized.repository.full_name} (id {normalized.repository.id})")
396
+ info(f" Merge group: {normalized.base_sha[:12]}..{normalized.head_sha[:12]}")
397
+ info(f" Action: {normalized.action.value}")
398
+ info(
399
+ " Result: "
400
+ + ("scan the merge group commit" if normalized.reason is None else "merge group ended")
401
+ )
402
+ else:
403
+ info(f" Installation: {normalized.installation_id}")
404
+ info(f" Repository: {normalized.repository.full_name} (id {normalized.repository.id})")
405
+ ctx = normalized.context
406
+ if isinstance(normalized, PullRequestEvent):
407
+ info(f" Pull request: #{normalized.number} ({normalized.action})")
408
+ info(f" Range: {(ctx.base_sha or '')[:12]}..{(ctx.head_sha or '')[:12]}")
409
+ info(f" Result: {disposition(normalized).value}")
410
+ elif isinstance(normalized, PushEvent):
411
+ info(f" Ref: {safe_text(ctx.ref or '', 200)}")
412
+ if ctx.ref_deleted:
413
+ info(" Result: ignored (branch deleted)")
414
+ else:
415
+ info(
416
+ f" Commits: {(ctx.before_sha or 'new ref')[:12]}..{(ctx.after_sha or '')[:12]}"
417
+ )
418
+ info(" Result: scan")
419
+ raise typer.Exit(code=int(ExitCode.ERROR if check.failed else ExitCode.OK))
420
+
421
+
422
+ @github_app.command("serve")
423
+ def serve_command(
424
+ host: Annotated[str, typer.Option("--host", help="Bind address.")] = "127.0.0.1",
425
+ port: Annotated[int, typer.Option("--port", min=1, max=65535)] = 8080,
426
+ ) -> None:
427
+ """Run the GitHub App service: webhooks, and the dashboard when configured.
428
+
429
+ Put a TLS-terminating reverse proxy in front of it.
430
+ """
431
+ with handled_errors():
432
+ _app_modules_available()
433
+ from commitguard.api.hosting import build_dashboard, create_server_app
434
+ from commitguard.api.settings import dashboard_enabled, load_dashboard_settings
435
+ from commitguard.github.app import service_from_environment
436
+ from commitguard.github.server import serve
437
+
438
+ configure_json_logging()
439
+ service = service_from_environment()
440
+ dashboard = (
441
+ build_dashboard(service, load_dashboard_settings()) if dashboard_enabled() else None
442
+ )
443
+ service.start()
444
+ try:
445
+ serve(create_server_app(service, dashboard), host=host, port=port)
446
+ except KeyboardInterrupt:
447
+ pass
448
+ finally:
449
+ service.stop()
@@ -0,0 +1,156 @@
1
+ """``commitguard hook <name>``: the stable interface called by installed Git hooks.
2
+
3
+ The hook scripts in ``.git/hooks`` only locate CommitGuard and call these
4
+ commands; all analysis happens in :mod:`commitguard.services.hooks`.
5
+
6
+ Exit codes: 0 allow (including warnings), 1 block, 2 error. Any error blocks
7
+ the Git operation (fail closed) with instructions to run ``commitguard doctor``.
8
+ All output goes to stderr, as Git expects from hooks.
9
+ """
10
+
11
+ import sys
12
+ from collections.abc import Iterator
13
+ from contextlib import contextmanager
14
+ from pathlib import Path
15
+ from typing import Annotated
16
+
17
+ import typer
18
+
19
+ from commitguard.cli.common import ConfigOption
20
+ from commitguard.cli.output import ExitCode
21
+ from commitguard.cli.render import render_commit_hook_text, render_push_text
22
+ from commitguard.core.decision import Action
23
+ from commitguard.git.repository import Repository
24
+ from commitguard.security.sanitization import sanitize_block
25
+ from commitguard.services.hooks import (
26
+ HookRun,
27
+ run_commit_msg,
28
+ run_pre_commit,
29
+ run_pre_push,
30
+ )
31
+
32
+ MAX_PRE_PUSH_STDIN_BYTES = 8 * 1024 * 1024
33
+
34
+ hook_app = typer.Typer(
35
+ help="Entry points used by installed Git hooks (stable interface).",
36
+ no_args_is_help=True,
37
+ )
38
+
39
+ VerboseOption = Annotated[
40
+ bool, typer.Option("--verbose", "-v", help="Show full evidence for every finding.")
41
+ ]
42
+
43
+
44
+ def _err(text: str) -> None:
45
+ if text:
46
+ typer.echo(text, err=True)
47
+
48
+
49
+ @contextmanager
50
+ def _fail_closed(operation: str) -> Iterator[None]:
51
+ """Any failure blocks the Git operation with an actionable message (exit 2)."""
52
+ try:
53
+ yield
54
+ except typer.Exit:
55
+ raise
56
+ except Exception as exc: # noqa: BLE001 - every failure must block, never allow
57
+ reason = sanitize_block(str(exc) or type(exc).__name__, max_length=2000)
58
+ _err(
59
+ "CommitGuard could not verify repository policy.\n"
60
+ f"Reason: {reason}\n"
61
+ f"{operation} blocked because the security check could not be completed.\n"
62
+ "Run: commitguard doctor"
63
+ )
64
+ raise typer.Exit(code=int(ExitCode.ERROR)) from None
65
+
66
+
67
+ def _removal_notice(run: HookRun) -> str:
68
+ """What ``remediation.auto_remove`` deleted. Never silent: the developer
69
+ wrote those lines and must see that they are gone."""
70
+ lines = [
71
+ "CommitGuard",
72
+ f"! REMOVED prohibited attribution from the commit message "
73
+ f"({len(run.removed)} line{'s' if len(run.removed) > 1 else ''})",
74
+ "",
75
+ ]
76
+ lines += [
77
+ f" line {removed.line_number}: {sanitize_block(removed.text, max_length=200)}"
78
+ f" [{removed.rule_id}]"
79
+ for removed in run.removed
80
+ ]
81
+ lines += [
82
+ "",
83
+ " The commit was created without them.",
84
+ " Turn this off with `remediation: {auto_remove: false}` to block instead.",
85
+ ]
86
+ return "\n".join(lines)
87
+
88
+
89
+ def _finish(run: HookRun, text: str) -> None:
90
+ if not run.enabled:
91
+ _err(
92
+ f"CommitGuard: {run.hook.value} enforcement is disabled by configuration; "
93
+ "no checks were run (see `commitguard doctor`)."
94
+ )
95
+ return
96
+ if run.removed:
97
+ _err(_removal_notice(run))
98
+ _err(text)
99
+ if run.report is not None and run.report.action is Action.BLOCK:
100
+ raise typer.Exit(code=int(ExitCode.BLOCKED))
101
+
102
+
103
+ @hook_app.command("pre-commit")
104
+ def pre_commit_command(config: ConfigOption = None, verbose: VerboseOption = False) -> None:
105
+ """Check the pending commit's author/committer identity."""
106
+ with _fail_closed("Commit"):
107
+ run = run_pre_commit(Repository.discover(), config_path=config)
108
+ text = (
109
+ render_commit_hook_text(run.report, stage="pre-commit", verbose=verbose)
110
+ if run.report
111
+ else ""
112
+ )
113
+ _finish(run, text)
114
+
115
+
116
+ @hook_app.command("commit-msg")
117
+ def commit_msg_command(
118
+ message_file: Annotated[Path, typer.Argument(help="Message file passed by Git.")],
119
+ config: ConfigOption = None,
120
+ verbose: VerboseOption = False,
121
+ ) -> None:
122
+ """Check the commit message (after Git's default cleanup) and pending identity."""
123
+ with _fail_closed("Commit"):
124
+ run = run_commit_msg(Repository.discover(), message_file, config_path=config)
125
+ text = (
126
+ render_commit_hook_text(run.report, stage="commit-msg", verbose=verbose)
127
+ if run.report
128
+ else ""
129
+ )
130
+ _finish(run, text)
131
+
132
+
133
+ @hook_app.command("pre-push")
134
+ def pre_push_command(
135
+ remote: Annotated[str, typer.Argument(help="Remote name passed by Git.")] = "",
136
+ url: Annotated[str, typer.Argument(help="Remote URL passed by Git.")] = "",
137
+ config: ConfigOption = None,
138
+ verbose: VerboseOption = False,
139
+ ) -> None:
140
+ """Check every commit a push would introduce (ref updates are read from stdin)."""
141
+ with _fail_closed("Push"):
142
+ data = sys.stdin.buffer.read(MAX_PRE_PUSH_STDIN_BYTES + 1)
143
+ if len(data) > MAX_PRE_PUSH_STDIN_BYTES:
144
+ raise ValueError("pre-push input is too large")
145
+ run = run_pre_push(
146
+ Repository.discover(),
147
+ remote or url,
148
+ data.decode("utf-8", errors="replace"),
149
+ config_path=config,
150
+ )
151
+ text = (
152
+ render_push_text(run.report, remote=remote or url, verbose=verbose)
153
+ if run.report
154
+ else ""
155
+ )
156
+ _finish(run, text)