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,447 @@
1
+ """Repository discovery and read-only repository queries.
2
+
3
+ Nothing in this module writes to the repository.
4
+ """
5
+
6
+ import secrets
7
+ from collections.abc import Iterator, Mapping, Sequence
8
+ from datetime import datetime
9
+ from pathlib import Path
10
+
11
+ from pydantic import ValidationError
12
+
13
+ from commitguard.exceptions.base import UnsafeInputError
14
+ from commitguard.exceptions.git import (
15
+ GitCommandError,
16
+ GitError,
17
+ MalformedGitOutputError,
18
+ NotAGitRepositoryError,
19
+ )
20
+ from commitguard.git.commands import run_git
21
+ from commitguard.git.commit import Commit
22
+ from commitguard.provenance.author import Identity
23
+ from commitguard.security.validation import (
24
+ is_git_sha,
25
+ validate_git_config_key,
26
+ validate_repository_path,
27
+ validate_revision,
28
+ )
29
+ from commitguard.utils.subprocess import CommandResult
30
+
31
+ # Fields are NUL-separated; the free-form message (%B) comes last so that any
32
+ # NUL bytes inside it cannot shift the positions of the structured fields.
33
+ _COMMIT_FIELDS = "%x00".join(["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%B"])
34
+ _COMMIT_FIELD_COUNT = 9
35
+ # Commits are read in batches; records are separated by a random per-call
36
+ # boundary that commit messages cannot predict.
37
+ _READ_BATCH_SIZE = 256
38
+
39
+
40
+ class Repository:
41
+ """A discovered Git repository with a work tree."""
42
+
43
+ def __init__(
44
+ self, root: Path, git_dir: Path, *, git_env: Mapping[str, str] | None = None
45
+ ) -> None:
46
+ self.root = root
47
+ self.git_dir = git_dir
48
+ # Extra environment for every git invocation on this repository (e.g. a
49
+ # server-side mirror disables lazy object fetching). Never holds secrets.
50
+ self._git_env = dict(git_env) if git_env else None
51
+
52
+ def __repr__(self) -> str:
53
+ return f"Repository(root={self.root!s})"
54
+
55
+ # ------------------------------------------------------------------ #
56
+ # Discovery
57
+ # ------------------------------------------------------------------ #
58
+ @classmethod
59
+ def discover(cls, path: Path | None = None) -> "Repository":
60
+ """Locate the repository containing ``path`` (default: CWD).
61
+
62
+ Raises :class:`NotAGitRepositoryError` if ``path`` is not inside a work
63
+ tree. Bare repositories are not supported yet.
64
+ """
65
+ start = (path or Path.cwd()).resolve()
66
+ try:
67
+ result = run_git(
68
+ [
69
+ "rev-parse",
70
+ "--path-format=absolute",
71
+ "--show-toplevel",
72
+ "--absolute-git-dir",
73
+ ],
74
+ cwd=start,
75
+ )
76
+ except GitCommandError as exc:
77
+ raise NotAGitRepositoryError(f"not inside a Git work tree: {start}") from exc
78
+
79
+ lines = result.stdout.decode("utf-8", errors="surrogateescape").splitlines()
80
+ if len(lines) != 2 or not all(lines):
81
+ raise NotAGitRepositoryError(f"not inside a Git work tree: {start}")
82
+ return cls(root=Path(lines[0]), git_dir=Path(lines[1]))
83
+
84
+ def _git(
85
+ self,
86
+ args: Sequence[str],
87
+ *,
88
+ check: bool = True,
89
+ input_bytes: bytes | None = None,
90
+ ) -> CommandResult:
91
+ return run_git(
92
+ args, cwd=self.root, check=check, input_bytes=input_bytes, extra_env=self._git_env
93
+ )
94
+
95
+ # ------------------------------------------------------------------ #
96
+ # Queries
97
+ # ------------------------------------------------------------------ #
98
+ def resolve_commit(self, revision: str) -> str:
99
+ """Resolve ``revision`` to a full commit object ID."""
100
+ validate_revision(revision)
101
+ result = self._git(
102
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", f"{revision}^{{commit}}"],
103
+ check=False,
104
+ )
105
+ sha = result.stdout.decode("ascii", errors="replace").strip()
106
+ if not result.ok or not is_git_sha(sha):
107
+ raise GitError("revision does not resolve to a commit")
108
+ return sha
109
+
110
+ def read_commit(self, revision: str = "HEAD") -> Commit:
111
+ """Read one commit's metadata into a normalised :class:`Commit`."""
112
+ return self.read_commits([self.resolve_commit(revision)])[0]
113
+
114
+ def list_commits(self, revision_range: str, *, max_count: int) -> list[str]:
115
+ """Return commit IDs selected by ``revision_range``, oldest first.
116
+
117
+ A plain revision (``HEAD``, a SHA, a branch) selects that single commit.
118
+ A range containing ``..`` (``origin/main..HEAD``) selects every commit in
119
+ the range, as ``git rev-list`` does. More than ``max_count`` commits is an
120
+ error rather than a silent truncation.
121
+ """
122
+ validate_revision(revision_range)
123
+ if ".." not in revision_range:
124
+ return [self.resolve_commit(revision_range)]
125
+ result = self._git(
126
+ [
127
+ "rev-list",
128
+ f"--max-count={max_count + 1}",
129
+ "--end-of-options",
130
+ revision_range,
131
+ "--",
132
+ ],
133
+ check=False,
134
+ )
135
+ if not result.ok:
136
+ raise GitError("revision range could not be resolved")
137
+ shas = result.stdout.decode("ascii", errors="replace").split()
138
+ if not all(is_git_sha(sha) for sha in shas):
139
+ raise MalformedGitOutputError("unexpected rev-list output")
140
+ if len(shas) > max_count:
141
+ raise GitError(f"revision range selects more than {max_count} commits")
142
+ shas.reverse()
143
+ return shas
144
+
145
+ def read_commits(self, shas: Sequence[str]) -> list[Commit]:
146
+ """Read metadata for full commit IDs, preserving order.
147
+
148
+ Mailmap rewriting, notes, signature display and replace refs are all
149
+ disabled so that the metadata inspected is exactly what the commit
150
+ objects record.
151
+ """
152
+ for sha in shas:
153
+ if not is_git_sha(sha):
154
+ raise UnsafeInputError("read_commits requires full commit ids")
155
+ commits: list[Commit] = []
156
+ for start in range(0, len(shas), _READ_BATCH_SIZE):
157
+ commits.extend(self._read_batch(shas[start : start + _READ_BATCH_SIZE]))
158
+ return commits
159
+
160
+ def iter_commits(self, shas: Sequence[str]) -> Iterator[Commit]:
161
+ """Like :meth:`read_commits`, but reads one batch at a time (bounded memory)."""
162
+ for sha in shas:
163
+ if not is_git_sha(sha):
164
+ raise UnsafeInputError("iter_commits requires full commit ids")
165
+ for start in range(0, len(shas), _READ_BATCH_SIZE):
166
+ yield from self._read_batch(shas[start : start + _READ_BATCH_SIZE])
167
+
168
+ def _read_batch(self, shas: Sequence[str]) -> list[Commit]:
169
+ boundary = secrets.token_hex(16)
170
+ result = self._git(
171
+ [
172
+ "-c",
173
+ "log.showSignature=false",
174
+ "-c",
175
+ "i18n.logOutputEncoding=UTF-8",
176
+ "log",
177
+ "--no-walk=unsorted",
178
+ "--no-use-mailmap",
179
+ "--no-notes",
180
+ "--no-color",
181
+ f"--pretty=format:%x00{boundary}%x00{_COMMIT_FIELDS}",
182
+ "--end-of-options",
183
+ *shas,
184
+ "--",
185
+ ],
186
+ )
187
+ records = result.stdout.split(f"\x00{boundary}\x00".encode("ascii"))
188
+ if records[0].strip():
189
+ raise MalformedGitOutputError("unexpected data before first commit record")
190
+ records = records[1:]
191
+ if len(records) != len(shas):
192
+ raise MalformedGitOutputError("commit record count does not match request")
193
+ return [
194
+ # git separates records with a newline, which is not part of the message
195
+ _parse_commit_record(
196
+ record.removesuffix(b"\n") if index < len(records) - 1 else record, expected_sha=sha
197
+ )
198
+ for index, (record, sha) in enumerate(zip(records, shas, strict=True))
199
+ ]
200
+
201
+ def pending_identities(self) -> tuple[Identity, Identity]:
202
+ """Author and committer Git would use for a new commit (``git var``)."""
203
+ return self._git_var_identity("GIT_AUTHOR_IDENT"), self._git_var_identity(
204
+ "GIT_COMMITTER_IDENT"
205
+ )
206
+
207
+ def _git_var_identity(self, variable: str) -> Identity:
208
+ result = self._git(["var", variable], check=False)
209
+ if not result.ok:
210
+ raise GitError(f"git could not determine {variable} (is user.name/user.email set?)")
211
+ line = result.stdout.decode("utf-8", errors="replace").rstrip("\n")
212
+ # "Name <email> 1700000000 +0100"
213
+ ident, _, _timezone = line.rpartition(" ")
214
+ ident, _, _timestamp = ident.rpartition(" ")
215
+ open_index, close_index = ident.rfind("<"), ident.rfind(">")
216
+ if open_index < 0 or close_index < open_index or close_index != len(ident) - 1:
217
+ raise MalformedGitOutputError(f"unexpected {variable} format")
218
+ return Identity(name=ident[:open_index].strip(), email=ident[open_index + 1 : close_index])
219
+
220
+ @property
221
+ def common_dir(self) -> Path:
222
+ """The Git directory shared by all worktrees (where hooks normally live)."""
223
+ result = self._git(["rev-parse", "--path-format=absolute", "--git-common-dir"])
224
+ return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip())
225
+
226
+ def peel_to_commit(self, oid: str) -> str | None:
227
+ """Return the commit an object (commit or annotated tag) refers to.
228
+
229
+ Returns None if the object does not exist locally or peels to a
230
+ non-commit object (e.g. a tag pointing at a tree or blob).
231
+ """
232
+ if not is_git_sha(oid):
233
+ raise UnsafeInputError("peel_to_commit requires a full object id")
234
+ result = self._git(
235
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", f"{oid}^{{commit}}"],
236
+ check=False,
237
+ )
238
+ sha = result.stdout.decode("ascii", errors="replace").strip()
239
+ return sha if result.ok and is_git_sha(sha) else None
240
+
241
+ def object_exists(self, oid: str) -> bool:
242
+ if not is_git_sha(oid):
243
+ raise UnsafeInputError("object_exists requires a full object id")
244
+ return self._git(["cat-file", "-e", oid], check=False).ok
245
+
246
+ def has_commit(self, oid: str) -> bool:
247
+ return is_git_sha(oid) and self.peel_to_commit(oid) == oid
248
+
249
+ def is_ancestor(self, ancestor: str, descendant: str) -> bool | None:
250
+ """Whether ``ancestor`` is reachable from ``descendant`` (a commit is its own ancestor).
251
+
252
+ Returns None when either commit is not available locally, so callers can
253
+ tell "not an ancestor" apart from "cannot tell".
254
+ """
255
+ if not (is_git_sha(ancestor) and is_git_sha(descendant)):
256
+ raise UnsafeInputError("is_ancestor requires full object ids")
257
+ if not (self.has_commit(ancestor) and self.has_commit(descendant)):
258
+ return None
259
+ result = self._git(["merge-base", "--is-ancestor", ancestor, descendant], check=False)
260
+ if result.returncode in (0, 1):
261
+ return result.returncode == 0
262
+ return None
263
+
264
+ def remote_exists(self, name: str) -> bool:
265
+ if not _is_simple_remote_name(name):
266
+ return False
267
+ return self.config_get(f"remote.{name}.url") is not None
268
+
269
+ def remote_tracking_tips(self, remote: str) -> list[str]:
270
+ """Commit IDs at the tips of ``refs/remotes/<remote>/*`` (what the remote had)."""
271
+ if not self.remote_exists(remote):
272
+ return []
273
+ # One call: object type/name and, for tags, the peeled type/name.
274
+ result = self._git(
275
+ [
276
+ "for-each-ref",
277
+ "--format=%(objecttype) %(objectname) %(*objecttype) %(*objectname)",
278
+ "--",
279
+ f"refs/remotes/{remote}/",
280
+ ],
281
+ )
282
+ tips = []
283
+ for line in result.stdout.decode("ascii", errors="replace").splitlines():
284
+ fields = line.split()
285
+ if len(fields) >= 2 and fields[0] == "commit" and is_git_sha(fields[1]):
286
+ tips.append(fields[1])
287
+ elif len(fields) == 4 and fields[2] == "commit" and is_git_sha(fields[3]):
288
+ tips.append(fields[3])
289
+ return sorted(set(tips))
290
+
291
+ def rev_list(
292
+ self,
293
+ include: Sequence[str],
294
+ exclude: Sequence[str] = (),
295
+ *,
296
+ max_count: int,
297
+ ) -> list[str]:
298
+ """Commits reachable from ``include`` but not from ``exclude``, oldest first.
299
+
300
+ All inputs must be full object IDs; they are passed on stdin (no argument
301
+ length limits, no option parsing). More than ``max_count`` results is an
302
+ error rather than a silent truncation.
303
+ """
304
+ for oid in (*include, *exclude):
305
+ if not is_git_sha(oid):
306
+ raise UnsafeInputError("rev_list requires full object ids")
307
+ if not include:
308
+ return []
309
+ stdin = "".join(f"{oid}\n" for oid in include) + "".join(f"^{oid}\n" for oid in exclude)
310
+ result = self._git(
311
+ ["rev-list", f"--max-count={max_count + 1}", "--stdin"],
312
+ input_bytes=stdin.encode("ascii"),
313
+ )
314
+ shas = result.stdout.decode("ascii", errors="replace").split()
315
+ if not all(is_git_sha(sha) for sha in shas):
316
+ raise MalformedGitOutputError("unexpected rev-list output")
317
+ if len(shas) > max_count:
318
+ raise GitError(f"more than {max_count} commits would need to be analysed")
319
+ shas.reverse()
320
+ return shas
321
+
322
+ def cleanup_message(self, message: str) -> str:
323
+ """Approximate the cleanup ``git commit`` applies before storing a message.
324
+
325
+ A commit-msg hook cannot see whether an editor was used or which
326
+ ``--cleanup`` option was given, so this errs towards *keeping* text:
327
+
328
+ * comment lines are stripped only when ``commit.cleanup=strip`` is
329
+ configured (with ``-m``/``-F``, Git's default keeps ``#`` lines, so
330
+ stripping them would hide attribution that really gets stored);
331
+ * everything from Git's scissors line is dropped unless the mode is
332
+ ``verbatim``/``whitespace`` (the diff ``git commit -v`` appends there
333
+ is never part of the message);
334
+ * whitespace is normalised with ``git stripspace`` unless ``verbatim``.
335
+
336
+ pre-push analyses the real commit objects and is the authoritative check.
337
+ """
338
+ mode = (self.config_get("commit.cleanup") or "default").lower()
339
+ if mode == "verbatim":
340
+ return message
341
+ if mode in ("default", "strip", "scissors"):
342
+ message = _cut_at_scissors(message)
343
+ args = ["stripspace"]
344
+ if mode == "strip":
345
+ args.append("--strip-comments")
346
+ result = self._git(args, input_bytes=message.encode("utf-8", "surrogatepass"))
347
+ return result.stdout.decode("utf-8", errors="replace")
348
+
349
+ def ref_commit(self, refname: str) -> str | None:
350
+ """The commit a fully qualified ref (``refs/...``) points to, if it exists."""
351
+ if not refname.startswith("refs/") or any(ord(c) < 0x20 for c in refname):
352
+ raise UnsafeInputError("ref_commit requires a fully qualified ref name")
353
+ result = self._git(
354
+ ["rev-parse", "--verify", "--quiet", "--end-of-options", f"{refname}^{{commit}}"],
355
+ check=False,
356
+ )
357
+ sha = result.stdout.decode("ascii", errors="replace").strip()
358
+ return sha if result.ok and is_git_sha(sha) else None
359
+
360
+ def read_blob_at(self, commit: str, path: str, *, max_bytes: int) -> bytes | None:
361
+ """Read a regular file from a commit's tree (not from the work tree).
362
+
363
+ Returns None if the path does not exist at that commit. Raises
364
+ :class:`UnsafeInputError` for non-regular entries (symlinks,
365
+ submodules, directories) or files larger than ``max_bytes``.
366
+ """
367
+ if not is_git_sha(commit):
368
+ raise UnsafeInputError("read_blob_at requires a full commit id")
369
+ validate_repository_path(path)
370
+ result = self._git(["ls-tree", "-z", "--end-of-options", commit, "--", path])
371
+ entries = [e for e in result.stdout.split(b"\x00") if e]
372
+ match = None
373
+ for entry in entries:
374
+ meta, _, name = entry.partition(b"\t")
375
+ if name.decode("utf-8", errors="surrogateescape") == path:
376
+ match = meta.decode("ascii", errors="replace").split()
377
+ if match is None:
378
+ return None
379
+ if len(match) != 3 or match[1] != "blob" or match[0] not in ("100644", "100755"):
380
+ raise UnsafeInputError(f"{path} at {commit[:12]} is not a regular file")
381
+ oid = match[2]
382
+ if not is_git_sha(oid):
383
+ raise MalformedGitOutputError("unexpected ls-tree output")
384
+ size = self._git(["cat-file", "-s", oid]).stdout.decode("ascii").strip()
385
+ if not size.isdigit() or int(size) > max_bytes:
386
+ raise UnsafeInputError(f"{path} at {commit[:12]} is larger than {max_bytes} bytes")
387
+ return self._git(["cat-file", "blob", oid]).stdout
388
+
389
+ def config_get(self, key: str) -> str | None:
390
+ """Return a Git configuration value, or None if it is unset."""
391
+ validate_git_config_key(key)
392
+ result = self._git(["config", "--get", "--end-of-options", key], check=False)
393
+ if result.returncode == 1: # key not set
394
+ return None
395
+ if not result.ok:
396
+ raise GitCommandError(result.args, result.returncode, "git config failed")
397
+ return result.stdout.decode("utf-8", errors="replace").rstrip("\n")
398
+
399
+ def hooks_dir(self) -> Path:
400
+ """Return the effective hooks directory (honours ``core.hooksPath``)."""
401
+ result = self._git(["rev-parse", "--path-format=absolute", "--git-path", "hooks"])
402
+ return Path(result.stdout.decode("utf-8", errors="surrogateescape").strip())
403
+
404
+
405
+ def _parse_commit_record(raw: bytes, *, expected_sha: str) -> Commit:
406
+ """Parse the NUL-separated output of :data:`_COMMIT_FIELDS` defensively."""
407
+ fields = raw.decode("utf-8", errors="replace").split("\x00", _COMMIT_FIELD_COUNT - 1)
408
+ if len(fields) != _COMMIT_FIELD_COUNT:
409
+ raise MalformedGitOutputError("unexpected commit record shape")
410
+
411
+ sha, parents, a_name, a_email, a_date, c_name, c_email, c_date, message = fields
412
+ if sha != expected_sha:
413
+ raise MalformedGitOutputError("commit record does not match requested object")
414
+
415
+ try:
416
+ return Commit(
417
+ sha=sha,
418
+ parents=tuple(parents.split()),
419
+ author=Identity(name=a_name, email=a_email),
420
+ committer=Identity(name=c_name, email=c_email),
421
+ authored_at=datetime.fromisoformat(a_date),
422
+ committed_at=datetime.fromisoformat(c_date),
423
+ message=message,
424
+ )
425
+ except (ValueError, UnsafeInputError, ValidationError) as exc:
426
+ raise MalformedGitOutputError(f"invalid commit metadata: {type(exc).__name__}") from exc
427
+
428
+
429
+ _SCISSORS = "------------------------ >8 ------------------------"
430
+
431
+
432
+ def _cut_at_scissors(message: str) -> str:
433
+ lines = message.split("\n")
434
+ for index, line in enumerate(lines):
435
+ stripped = line.strip()
436
+ if stripped.endswith(_SCISSORS) and len(stripped) <= len(_SCISSORS) + 4:
437
+ return "\n".join(lines[:index]) + ("\n" if index else "")
438
+ return message
439
+
440
+
441
+ def _is_simple_remote_name(name: str) -> bool:
442
+ """A configured remote name usable in ref patterns (no globs, paths or URLs)."""
443
+ return (
444
+ 0 < len(name) <= 200
445
+ and not name.startswith(("-", "."))
446
+ and all(ch.isalnum() or ch in "._-" for ch in name)
447
+ )
@@ -0,0 +1,34 @@
1
+ """GitHub integration: GitHub Actions (Phase 4) and the CommitGuard GitHub App (Phase 5).
2
+
3
+ Both are adapters around the same core: they turn GitHub events into a
4
+ :class:`~commitguard.ci.context.CIContext`, run
5
+ :class:`~commitguard.services.scan.ScanService` (the detection engine and policy
6
+ evaluator shared with the CLI and Git hooks) and translate the result into
7
+ GitHub output. Nothing in this package detects anything or decides policy.
8
+
9
+ GitHub Actions (no network, no token):
10
+
11
+ * :mod:`~commitguard.github.events` - event payloads -> ``CIContext``;
12
+ * :mod:`~commitguard.github.actions` - workflow commands, job summary, outputs;
13
+ * :mod:`~commitguard.github.checks` - check output model and conclusions;
14
+ * :mod:`~commitguard.github.workflow` - workflow template and static inspection.
15
+
16
+ GitHub App (webhooks, Checks API; needs the optional ``app`` extra):
17
+
18
+ * :mod:`~commitguard.github.settings` - environment configuration;
19
+ * :mod:`~commitguard.github.auth` - App JWT, down-scoped installation tokens;
20
+ * :mod:`~commitguard.github.client` - small REST client (retries, rate limits);
21
+ * :mod:`~commitguard.github.webhooks` - signature, header, size and JSON validation;
22
+ * :mod:`~commitguard.github.events` - webhook normalisation (typed events);
23
+ * :mod:`~commitguard.github.installations` - installation lifecycle and authorization;
24
+ * :mod:`~commitguard.github.repositories` - metadata-only Git mirrors (no checkout);
25
+ * :mod:`~commitguard.github.storage` - deliveries, installations, jobs, audit (SQLite);
26
+ * :mod:`~commitguard.github.queue` - event queue abstraction;
27
+ * :mod:`~commitguard.github.worker` - scan worker and Check Run lifecycle;
28
+ * :mod:`~commitguard.github.check_runs` - Check Run content;
29
+ * :mod:`~commitguard.github.app` - service wiring and WSGI endpoint;
30
+ * :mod:`~commitguard.github.server` - development HTTP server.
31
+
32
+ A failing check blocks merges only when branch protection or a ruleset
33
+ requires it; CommitGuard does not configure or verify those settings.
34
+ """
@@ -0,0 +1,163 @@
1
+ """GitHub Actions runtime integration: workflow commands, job summary, outputs.
2
+
3
+ Everything printed in a GitHub Actions log can be interpreted as a workflow
4
+ command (``::set-env``, ``::add-mask::`` ...). Commit messages, author names and
5
+ branch names are attacker-controlled, so:
6
+
7
+ * human-readable output containing untrusted text is wrapped in
8
+ ``::stop-commands::<random token>`` ... ``::<token>::``;
9
+ * annotation messages and properties are escaped exactly as ``@actions/core``
10
+ does, after control characters are made visible;
11
+ * the job summary is Markdown with every untrusted value escaped;
12
+ * ``$GITHUB_OUTPUT`` only ever receives enum values and integers.
13
+
14
+ No GitHub API calls and no token are needed.
15
+ """
16
+
17
+ import os
18
+ import secrets
19
+ from collections.abc import Callable, Iterator, Mapping
20
+ from contextlib import contextmanager
21
+ from pathlib import Path
22
+
23
+ from commitguard.core.decision import Action
24
+ from commitguard.github.checks import AnnotationLevel, CheckOutput
25
+ from commitguard.github.markdown import escape_markdown
26
+ from commitguard.security.sanitization import sanitize_for_terminal
27
+ from commitguard.services.reports import ScanReport
28
+
29
+ MAX_SUMMARY_FINDINGS = 50
30
+ _COMMAND = {
31
+ AnnotationLevel.FAILURE: "error",
32
+ AnnotationLevel.WARNING: "warning",
33
+ AnnotationLevel.NOTICE: "notice",
34
+ }
35
+
36
+
37
+ def running_in_github_actions(environ: Mapping[str, str] | None = None) -> bool:
38
+ return (environ if environ is not None else os.environ).get("GITHUB_ACTIONS") == "true"
39
+
40
+
41
+ def escape_data(value: str) -> str:
42
+ return value.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
43
+
44
+
45
+ def escape_property(value: str) -> str:
46
+ return escape_data(value).replace(":", "%3A").replace(",", "%2C")
47
+
48
+
49
+ def workflow_command(command: str, message: str, **properties: str) -> str:
50
+ safe_message = escape_data(sanitize_for_terminal(message, max_length=1000))
51
+ props = ",".join(
52
+ f"{key}={escape_property(sanitize_for_terminal(value, max_length=200))}"
53
+ for key, value in properties.items()
54
+ )
55
+ return f"::{command}{' ' + props if props else ''}::{safe_message}"
56
+
57
+
58
+ def annotation_commands(output: CheckOutput) -> list[str]:
59
+ lines = [
60
+ workflow_command(_COMMAND[a.level], a.message, title=a.title) for a in output.annotations
61
+ ]
62
+ if output.omitted_annotations:
63
+ lines.append(
64
+ workflow_command(
65
+ "notice",
66
+ f"{output.omitted_annotations} more finding(s) not annotated; see the job summary",
67
+ title="CommitGuard",
68
+ )
69
+ )
70
+ return lines
71
+
72
+
73
+ @contextmanager
74
+ def commands_stopped(emit: Callable[[str], None]) -> Iterator[None]:
75
+ """Disable workflow command processing while untrusted text is printed."""
76
+ token = secrets.token_hex(16)
77
+ emit(f"::stop-commands::{token}")
78
+ try:
79
+ yield
80
+ finally:
81
+ emit(f"::{token}::")
82
+
83
+
84
+ _md = escape_markdown
85
+
86
+
87
+ def render_step_summary(report: ScanReport, output: CheckOutput) -> str:
88
+ ci = report.ci
89
+ icon = {"failure": "FAILED", "success": "PASSED"}[output.conclusion]
90
+ lines = [f"## {icon} {_md(output.title)}", ""]
91
+ if ci is not None:
92
+ lines += [
93
+ "| | |",
94
+ "|---|---|",
95
+ f"| Event | {_md(ci.event)}"
96
+ + (f" (PR \\#{ci.pull_request_number})" if ci.pull_request_number else "")
97
+ + (" from a fork" if ci.from_fork else "")
98
+ + " |",
99
+ f"| Range | {_md((ci.base_sha or '')[:12])}..{_md((ci.head_sha or '')[:12])} |",
100
+ f"| Policy source | {_md(ci.policy_source)} |",
101
+ ]
102
+ counts = output.counts
103
+ lines += [
104
+ f"| Commits scanned | {counts['commits']} |",
105
+ f"| Violations | {counts['block']} |",
106
+ f"| Warnings | {counts['warn']} |",
107
+ f"| Allowed | {counts['allow']} |",
108
+ f"| Result | **{report.action.value.upper()}** |",
109
+ "",
110
+ ]
111
+ if ci is not None and ci.notices:
112
+ lines += ["### Notices", ""] + [f"- {_md(n, 400)}" for n in ci.notices] + [""]
113
+
114
+ rows = []
115
+ for commit in sorted(report.commits, key=lambda c: -c.action.rank):
116
+ for failure in commit.failures:
117
+ rows.append(
118
+ f"| {_md(commit.short_sha)} | detector failure | {_md(failure.failure.detector)} "
119
+ f"| - | {_md(failure.failure.message)} | {failure.action.value} |"
120
+ )
121
+ for item in commit.findings:
122
+ rows.append(
123
+ f"| {_md(commit.short_sha)} | {_md(item.finding.title)} | {item.finding.rule_id} "
124
+ f"| {item.finding.severity.value} | {_md(item.finding.evidence[0].value)} "
125
+ f"| {item.action.value} |"
126
+ )
127
+ if rows:
128
+ lines += [
129
+ "### Findings",
130
+ "",
131
+ "| Commit | Finding | Rule | Severity | Evidence | Action |",
132
+ "|---|---|---|---|---|---|",
133
+ *rows[:MAX_SUMMARY_FINDINGS],
134
+ ]
135
+ if len(rows) > MAX_SUMMARY_FINDINGS:
136
+ lines.append(f"\n{len(rows) - MAX_SUMMARY_FINDINGS} more finding(s) omitted.")
137
+ lines.append("")
138
+ if report.action is Action.BLOCK:
139
+ lines += [
140
+ "### Remediation",
141
+ "",
142
+ "Update the listed commits so that they comply with the repository's contribution "
143
+ "policy, then push the updated branch. CommitGuard does not rewrite Git history.",
144
+ "",
145
+ ]
146
+ return "\n".join(lines) + "\n"
147
+
148
+
149
+ def append_file(path: Path, text: str) -> None:
150
+ with path.open("a", encoding="utf-8", newline="\n") as handle:
151
+ handle.write(text)
152
+
153
+
154
+ def step_outputs(report: ScanReport, output: CheckOutput) -> str:
155
+ counts = output.counts
156
+ values = {
157
+ "result": report.action.value,
158
+ "conclusion": output.conclusion,
159
+ "commits": counts["commits"],
160
+ "violations": counts["block"],
161
+ "warnings": counts["warn"],
162
+ }
163
+ return "".join(f"{key}={value}\n" for key, value in values.items())