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,254 @@
1
+ """Git hook runtime: what ``commitguard hook <name>`` does.
2
+
3
+ Hooks are enforcement points only. Every stage builds commits from Git data
4
+ and runs the same :class:`~commitguard.services.analysis.Analyzer` used by
5
+ ``scan``/``check``.
6
+
7
+ ============= =========================================== ==================
8
+ Stage Input What is analysed
9
+ ============= =========================================== ==================
10
+ pre-commit pending author/committer (``git var``) identity rules
11
+ commit-msg message file after Git's default cleanup message + identity
12
+ plus pending author/committer
13
+ pre-push ref updates on stdin every outgoing commit
14
+ ============= =========================================== ==================
15
+
16
+ commit-msg sees the message *before* Git creates the commit object; options
17
+ such as ``--cleanup=verbatim`` are invisible to it. pre-push analyses the real
18
+ commit objects and is the authoritative local check.
19
+ """
20
+
21
+ from collections.abc import Callable, Sequence
22
+ from enum import StrEnum
23
+ from pathlib import Path
24
+
25
+ from pydantic import BaseModel, ConfigDict
26
+
27
+ from commitguard.config.enforcement import (
28
+ Enforcement,
29
+ Remediation,
30
+ build_enforcement,
31
+ build_remediation,
32
+ )
33
+ from commitguard.config.loader import LoadedConfig
34
+ from commitguard.core.context import ScanTrigger
35
+ from commitguard.core.decision import Action
36
+ from commitguard.exceptions.git import GitError
37
+ from commitguard.git.push import PushUpdate, parse_pre_push_input
38
+ from commitguard.git.ranges import resolve_commit_range
39
+ from commitguard.git.repository import Repository
40
+ from commitguard.services.analysis import (
41
+ MAX_MESSAGE_FILE_BYTES,
42
+ Analyzer,
43
+ build_report,
44
+ load_analyzer,
45
+ pending_commit,
46
+ )
47
+ from commitguard.services.remediation import (
48
+ RemovedLine,
49
+ removable_lines,
50
+ strip_lines,
51
+ with_actual_text,
52
+ )
53
+ from commitguard.services.reports import ScanReport
54
+ from commitguard.utils.filesystem import read_bytes_limited
55
+
56
+
57
+ class HookName(StrEnum):
58
+ PRE_COMMIT = "pre-commit"
59
+ COMMIT_MSG = "commit-msg"
60
+ PRE_PUSH = "pre-push"
61
+
62
+
63
+ class UpdateDisposition(StrEnum):
64
+ SCANNED = "scanned"
65
+ DELETED = "deleted" # nothing to analyse
66
+ NON_COMMIT = "non_commit" # e.g. tag pointing at a tree or blob
67
+ UP_TO_DATE = "up_to_date" # no commits the remote does not already have
68
+
69
+
70
+ class UpdatePlan(BaseModel):
71
+ model_config = ConfigDict(frozen=True, extra="forbid")
72
+
73
+ update: PushUpdate
74
+ disposition: UpdateDisposition
75
+ commits: tuple[str, ...] = ()
76
+
77
+
78
+ class HookRun(BaseModel):
79
+ """Outcome of one hook invocation."""
80
+
81
+ model_config = ConfigDict(frozen=True, extra="forbid")
82
+
83
+ hook: HookName
84
+ enabled: bool
85
+ report: ScanReport | None = None
86
+ updates: tuple[UpdatePlan, ...] = ()
87
+ remote: str | None = None
88
+ #: Lines deleted from the pending message by ``remediation.auto_remove``.
89
+ #: Non-empty only when the rewritten message then analysed clean.
90
+ removed: tuple[RemovedLine, ...] = ()
91
+
92
+
93
+ def _setup(
94
+ repository: Repository, config_path: Path | None
95
+ ) -> tuple[Analyzer, LoadedConfig, Enforcement, Remediation]:
96
+ analyzer, loaded = load_analyzer(repository, config_path=config_path)
97
+ return (
98
+ analyzer,
99
+ loaded,
100
+ build_enforcement(*loaded.configs),
101
+ build_remediation(*loaded.configs),
102
+ )
103
+
104
+
105
+ def run_pre_commit(repository: Repository, *, config_path: Path | None = None) -> HookRun:
106
+ analyzer, loaded, enforcement, _ = _setup(repository, config_path)
107
+ if not enforcement.pre_commit:
108
+ return HookRun(hook=HookName.PRE_COMMIT, enabled=False)
109
+ author, committer = repository.pending_identities()
110
+ # No message exists yet: only identity-based rules can match.
111
+ report = analyzer.analyze(pending_commit("", author, committer), ScanTrigger.PRE_COMMIT)
112
+ return HookRun(
113
+ hook=HookName.PRE_COMMIT,
114
+ enabled=True,
115
+ report=build_report(
116
+ [report],
117
+ repository=repository,
118
+ target="pending commit",
119
+ trigger=ScanTrigger.PRE_COMMIT,
120
+ config=loaded,
121
+ ),
122
+ )
123
+
124
+
125
+ def run_commit_msg(
126
+ repository: Repository, message_file: Path, *, config_path: Path | None = None
127
+ ) -> HookRun:
128
+ analyzer, loaded, enforcement, remediation = _setup(repository, config_path)
129
+ if not enforcement.commit_msg:
130
+ return HookRun(hook=HookName.COMMIT_MSG, enabled=False)
131
+ raw = read_bytes_limited(message_file, max_bytes=MAX_MESSAGE_FILE_BYTES)
132
+ message = repository.cleanup_message(raw.decode("utf-8", errors="replace"))
133
+ author, committer = repository.pending_identities()
134
+
135
+ def analyse(text: str) -> ScanReport:
136
+ commit = analyzer.analyze(pending_commit(text, author, committer), ScanTrigger.COMMIT_MSG)
137
+ return build_report(
138
+ [commit],
139
+ repository=repository,
140
+ target="pending commit message",
141
+ trigger=ScanTrigger.COMMIT_MSG,
142
+ config=loaded,
143
+ )
144
+
145
+ report = analyse(message)
146
+ removed: tuple[RemovedLine, ...] = ()
147
+ if remediation.auto_remove and report.action is Action.BLOCK:
148
+ fixed = _auto_remove(message_file, message, report, analyse)
149
+ if fixed is not None:
150
+ report, removed = fixed
151
+ return HookRun(hook=HookName.COMMIT_MSG, enabled=True, report=report, removed=removed)
152
+
153
+
154
+ def _auto_remove(
155
+ message_file: Path,
156
+ message: str,
157
+ report: ScanReport,
158
+ analyse: Callable[[str], ScanReport],
159
+ ) -> tuple[ScanReport, tuple[RemovedLine, ...]] | None:
160
+ """Delete the offending lines, if that genuinely clears the block.
161
+
162
+ Returns the report for the rewritten message and what was removed, or
163
+ ``None`` to leave the original block in place. Every path that is not
164
+ provably clean returns ``None``: the caller then blocks as usual.
165
+ """
166
+ removals = removable_lines(report.commits[0]) if report.commits else None
167
+ if removals is None:
168
+ return None
169
+ rewritten = strip_lines(message, frozenset(r.line_number for r in removals))
170
+ if not rewritten.strip():
171
+ return None # nothing would be left; refuse rather than empty the message
172
+ after = analyse(rewritten)
173
+ if after.action is Action.BLOCK:
174
+ return None # the removal did not fix it: never allow on assumption
175
+ message_file.write_text(rewritten, encoding="utf-8")
176
+ return after, with_actual_text(message, removals)
177
+
178
+
179
+ def plan_push(
180
+ repository: Repository,
181
+ updates: Sequence[PushUpdate],
182
+ remote: str,
183
+ *,
184
+ max_commits: int,
185
+ ) -> tuple[list[UpdatePlan], list[str]]:
186
+ """Work out which commits a push would introduce, deduplicated, oldest first.
187
+
188
+ For each update the commits reachable from the local object but not from
189
+ the remote's current object (when present locally) or from any of the
190
+ remote's tracking refs are selected. That covers new branches (all-zero
191
+ remote object) without rescanning history the remote already has, and
192
+ force pushes (only commits the remote lacks). Deletions have no outgoing
193
+ commits; tags are peeled to the commit they reference.
194
+ """
195
+ known_remote = repository.remote_tracking_tips(remote)
196
+ plans: list[UpdatePlan] = []
197
+ ordered: dict[str, None] = {}
198
+ for update in updates:
199
+ if update.is_delete:
200
+ plans.append(UpdatePlan(update=update, disposition=UpdateDisposition.DELETED))
201
+ continue
202
+ commit = repository.peel_to_commit(update.local_oid)
203
+ if commit is None:
204
+ plans.append(UpdatePlan(update=update, disposition=UpdateDisposition.NON_COMMIT))
205
+ continue
206
+ exclude = set(known_remote)
207
+ remote_commit = None if update.is_new_ref else repository.peel_to_commit(update.remote_oid)
208
+ if remote_commit is not None:
209
+ exclude.add(remote_commit)
210
+ commits = resolve_commit_range(
211
+ repository, commit, exclude, base=remote_commit, max_count=max_commits
212
+ ).commits
213
+ for sha in commits:
214
+ ordered.setdefault(sha, None)
215
+ if len(ordered) > max_commits:
216
+ raise GitError(f"push would introduce more than {max_commits} commits")
217
+ plans.append(
218
+ UpdatePlan(
219
+ update=update,
220
+ disposition=UpdateDisposition.SCANNED if commits else UpdateDisposition.UP_TO_DATE,
221
+ commits=tuple(commits),
222
+ )
223
+ )
224
+ return plans, list(ordered)
225
+
226
+
227
+ def run_pre_push(
228
+ repository: Repository,
229
+ remote: str,
230
+ stdin_text: str,
231
+ *,
232
+ config_path: Path | None = None,
233
+ ) -> HookRun:
234
+ analyzer, loaded, enforcement, _ = _setup(repository, config_path)
235
+ if not enforcement.pre_push:
236
+ return HookRun(hook=HookName.PRE_PUSH, enabled=False, remote=remote)
237
+ updates = parse_pre_push_input(stdin_text)
238
+ plans, shas = plan_push(repository, updates, remote, max_commits=enforcement.max_push_commits)
239
+ reports = [
240
+ analyzer.analyze(commit, ScanTrigger.PRE_PUSH) for commit in repository.read_commits(shas)
241
+ ]
242
+ return HookRun(
243
+ hook=HookName.PRE_PUSH,
244
+ enabled=True,
245
+ remote=remote,
246
+ updates=tuple(plans),
247
+ report=build_report(
248
+ reports,
249
+ repository=repository,
250
+ target=f"push to {remote}",
251
+ trigger=ScanTrigger.PRE_PUSH,
252
+ config=loaded,
253
+ ),
254
+ )
@@ -0,0 +1,99 @@
1
+ """Removing prohibited attribution from a pending commit message.
2
+
3
+ Only the ``commit-msg`` hook can do this safely. It runs *before* Git creates
4
+ the commit object, so deleting a line rewrites nothing: there is no history to
5
+ rewrite yet. By the time ``pre-push`` runs, the commits exist and correcting
6
+ them means rewriting history, which CommitGuard never does on its own.
7
+
8
+ The rules are deliberately narrow, because this feature turns a block into a
9
+ commit:
10
+
11
+ * it applies only when **every** blocking finding points at a message line.
12
+ Attribution carried by the author or committer identity cannot be fixed by
13
+ editing text, so it still blocks;
14
+ * a detector failure always blocks - a message that could not be fully analysed
15
+ is never "fixed";
16
+ * the stripped message is **re-analysed**, and the commit proceeds only if the
17
+ result is clean. Nothing is ever allowed on the assumption that the removal
18
+ worked;
19
+ * if nothing but blank lines would be left, the commit blocks instead, rather
20
+ than producing an empty message.
21
+ """
22
+
23
+ from pydantic import BaseModel, ConfigDict
24
+
25
+ from commitguard.core.decision import Action
26
+ from commitguard.core.result import EvidenceSource
27
+ from commitguard.services.reports import CommitReport
28
+
29
+ #: Evidence that lives on a line of the message, and so can be deleted.
30
+ REMOVABLE_SOURCES = frozenset(
31
+ {EvidenceSource.COAUTHOR_TRAILER, EvidenceSource.TRAILER, EvidenceSource.MESSAGE}
32
+ )
33
+
34
+
35
+ class RemovedLine(BaseModel):
36
+ """One line deleted from a pending commit message."""
37
+
38
+ model_config = ConfigDict(frozen=True, extra="forbid")
39
+
40
+ line_number: int
41
+ text: str
42
+ rule_id: str
43
+
44
+
45
+ def removable_lines(report: CommitReport) -> tuple[RemovedLine, ...] | None:
46
+ """The message lines whose deletion would clear every block, or ``None``.
47
+
48
+ ``None`` means "editing the message cannot fix this": an identity-based
49
+ finding, a detector failure, or evidence with no line number.
50
+ """
51
+ if any(failure.action is Action.BLOCK for failure in report.failures):
52
+ return None
53
+ removals: dict[int, RemovedLine] = {}
54
+ for evaluated in report.findings:
55
+ if evaluated.action is not Action.BLOCK:
56
+ continue # warnings do not block, so nothing has to be removed for them
57
+ for evidence in evaluated.finding.evidence:
58
+ if evidence.source not in REMOVABLE_SOURCES or evidence.line_number is None:
59
+ return None
60
+ removals.setdefault(
61
+ evidence.line_number,
62
+ RemovedLine(
63
+ line_number=evidence.line_number,
64
+ text=evidence.value,
65
+ rule_id=evaluated.finding.rule_id,
66
+ ),
67
+ )
68
+ return tuple(removals[number] for number in sorted(removals)) or None
69
+
70
+
71
+ def with_actual_text(message: str, removals: tuple[RemovedLine, ...]) -> tuple[RemovedLine, ...]:
72
+ """Replace each removal's evidence value with the message line itself.
73
+
74
+ Evidence carries the matched value (``Claude <noreply@anthropic.com>``);
75
+ the developer needs to see the whole line that disappeared.
76
+ """
77
+ lines = message.splitlines()
78
+ return tuple(
79
+ removed.model_copy(update={"text": lines[removed.line_number - 1].strip()})
80
+ if 0 < removed.line_number <= len(lines)
81
+ else removed
82
+ for removed in removals
83
+ )
84
+
85
+
86
+ def strip_lines(message: str, line_numbers: frozenset[int]) -> str:
87
+ """Return ``message`` without the given 1-based lines.
88
+
89
+ Blank lines left stranded at the end are trimmed, so deleting a trailing
90
+ trailer block does not leave the message ending in blank lines.
91
+ """
92
+ kept = [
93
+ line
94
+ for number, line in enumerate(message.splitlines(), start=1)
95
+ if number not in line_numbers
96
+ ]
97
+ while kept and not kept[-1].strip():
98
+ kept.pop()
99
+ return "\n".join(kept) + "\n" if kept else ""
@@ -0,0 +1,146 @@
1
+ """Structured, serialisable analysis reports.
2
+
3
+ Reports are the stable output contract for the CLI's JSON format and the
4
+ future audit log. They contain commit IDs, findings, evidence (concise
5
+ metadata only) and decisions - never file contents or full commit messages.
6
+ """
7
+
8
+ from datetime import datetime
9
+ from typing import Literal
10
+
11
+ from pydantic import BaseModel, ConfigDict, Field, computed_field
12
+
13
+ from commitguard.core.decision import Action, Decision
14
+ from commitguard.core.result import DetectionResult, DetectorFailure, Finding
15
+
16
+ REPORT_SCHEMA_VERSION: Literal[1] = 1
17
+ MAX_SUBJECT_CHARS = 120
18
+
19
+
20
+ class EvaluatedFinding(BaseModel):
21
+ """A finding together with the policy outcome applied to it."""
22
+
23
+ model_config = ConfigDict(frozen=True, extra="forbid")
24
+
25
+ finding: Finding
26
+ action: Action
27
+ policy_id: str | None
28
+ reason: str
29
+
30
+ @computed_field # type: ignore[prop-decorator]
31
+ @property
32
+ def fingerprint(self) -> str:
33
+ return self.finding.fingerprint
34
+
35
+
36
+ class EvaluatedFailure(BaseModel):
37
+ model_config = ConfigDict(frozen=True, extra="forbid")
38
+
39
+ failure: DetectorFailure
40
+ action: Action
41
+ reason: str
42
+
43
+
44
+ class CommitReport(BaseModel):
45
+ """Detection + decision for one commit."""
46
+
47
+ model_config = ConfigDict(frozen=True, extra="forbid")
48
+
49
+ commit_sha: str | None
50
+ short_sha: str
51
+ subject: str = Field(default="", description="First line of the message, truncated")
52
+ action: Action
53
+ findings: tuple[EvaluatedFinding, ...] = ()
54
+ failures: tuple[EvaluatedFailure, ...] = ()
55
+ detectors_run: tuple[str, ...] = ()
56
+ detectors_skipped: tuple[str, ...] = ()
57
+
58
+ @classmethod
59
+ def build(
60
+ cls,
61
+ short_sha: str,
62
+ detection: DetectionResult,
63
+ decision: Decision,
64
+ *,
65
+ subject: str = "",
66
+ ) -> "CommitReport":
67
+ findings: list[EvaluatedFinding] = []
68
+ failures: list[EvaluatedFailure] = []
69
+ for explanation in decision.explanations:
70
+ if explanation.finding is not None:
71
+ findings.append(
72
+ EvaluatedFinding(
73
+ finding=explanation.finding,
74
+ action=explanation.action,
75
+ policy_id=explanation.policy_id,
76
+ reason=explanation.reason,
77
+ )
78
+ )
79
+ elif explanation.failure is not None:
80
+ failures.append(
81
+ EvaluatedFailure(
82
+ failure=explanation.failure,
83
+ action=explanation.action,
84
+ reason=explanation.reason,
85
+ )
86
+ )
87
+ return cls(
88
+ commit_sha=detection.commit_sha,
89
+ short_sha=short_sha,
90
+ subject=subject[:MAX_SUBJECT_CHARS],
91
+ action=decision.action,
92
+ findings=tuple(findings),
93
+ failures=tuple(failures),
94
+ detectors_run=detection.detectors_run,
95
+ detectors_skipped=detection.detectors_skipped,
96
+ )
97
+
98
+
99
+ class CIReport(BaseModel):
100
+ """CI context attached to reports produced by ``commitguard ci``."""
101
+
102
+ model_config = ConfigDict(frozen=True, extra="forbid")
103
+
104
+ provider: str
105
+ event: str
106
+ repository: str | None = None
107
+ ref: str | None = None
108
+ pull_request_number: int | None = None
109
+ from_fork: bool = False
110
+ base_sha: str | None = None
111
+ head_sha: str | None = None
112
+ policy_source: str
113
+ config_changes: tuple[str, ...] = Field(
114
+ default=(), description="Config files changed by the evaluated commits (not applied)"
115
+ )
116
+ policy_weakenings: tuple[str, ...] = Field(
117
+ default=(), description="Policies the evaluated commits' config would weaken (not applied)"
118
+ )
119
+ notices: tuple[str, ...] = ()
120
+
121
+
122
+ class ScanReport(BaseModel):
123
+ """Result of analysing one or more commits."""
124
+
125
+ model_config = ConfigDict(frozen=True, extra="forbid")
126
+
127
+ schema_version: Literal[1] = REPORT_SCHEMA_VERSION
128
+ tool_version: str
129
+ generated_at: datetime
130
+ repository: str | None
131
+ target: str
132
+ trigger: str
133
+ config_sources: tuple[str, ...]
134
+ action: Action
135
+ commits: tuple[CommitReport, ...]
136
+ ci: CIReport | None = None
137
+
138
+ @computed_field # type: ignore[prop-decorator]
139
+ @property
140
+ def summary(self) -> dict[str, int]:
141
+ counts = {action.value: 0 for action in Action}
142
+ for commit in self.commits:
143
+ actions = [f.action for f in commit.findings] + [f.action for f in commit.failures]
144
+ for action in actions:
145
+ counts[action.value] += 1
146
+ return {"commits": len(self.commits), **counts}
@@ -0,0 +1,172 @@
1
+ """ScanService: one entry point for "analyse these commits under this policy".
2
+
3
+ Every server-side adapter (``commitguard ci github`` in GitHub Actions and the
4
+ GitHub App worker) builds a :class:`ScanRequest` and gets a :class:`ScanResult`
5
+ containing the same :class:`~commitguard.services.reports.ScanReport` that
6
+ ``commitguard scan --format json`` produces - there is no platform-specific
7
+ result format and no platform-specific detection.
8
+
9
+ A scan is deterministic: the same commits, trusted policy, mandatory policy,
10
+ rules and CommitGuard version produce the same result and the same
11
+ :attr:`ScanMetadata.scan_id`, which makes retries, duplicate webhooks and
12
+ Action/App comparisons checkable.
13
+ """
14
+
15
+ from pydantic import BaseModel, ConfigDict, Field
16
+
17
+ from commitguard import __version__
18
+ from commitguard.ci.context import CIContext
19
+ from commitguard.config.sources import MandatoryPolicy
20
+ from commitguard.core.decision import Action
21
+ from commitguard.git.repository import Repository
22
+ from commitguard.policies.governance import EffectivePolicy, GovernanceInputs
23
+ from commitguard.policies.model import Policy
24
+ from commitguard.rules.loader import builtin_rules_fingerprint
25
+ from commitguard.rules.matcher import CompiledRules
26
+ from commitguard.security.hashing import fingerprint
27
+ from commitguard.security.validation import validate_repository_path
28
+ from commitguard.services.ci import DEFAULT_CI_MAX_COMMITS, CIPlan, execute_ci_plan, plan_ci
29
+ from commitguard.services.enforcement import EnforcementDecision, EnforcementService
30
+ from commitguard.services.reports import ScanReport
31
+
32
+ SCAN_ID_LENGTH = 32
33
+
34
+
35
+ class ScanRequest(BaseModel):
36
+ model_config = ConfigDict(frozen=True, extra="forbid", arbitrary_types_allowed=True)
37
+
38
+ repository: Repository
39
+ context: CIContext
40
+ config_path: str | None = None
41
+ max_commits: int = Field(default=DEFAULT_CI_MAX_COMMITS, ge=1)
42
+ fail_on: Action = Action.BLOCK
43
+ mandatory_policy: MandatoryPolicy | None = None
44
+ #: Organization governance (GitHub App); replaces ``mandatory_policy`` when set.
45
+ governance: GovernanceInputs | None = None
46
+ rules: CompiledRules | None = None # None: the bundled, trusted rules
47
+ #: Version label of ``rules`` when they are not the bundled rules.
48
+ rules_version: str | None = None
49
+
50
+ def model_post_init(self, __context: object) -> None:
51
+ if self.config_path is not None:
52
+ validate_repository_path(self.config_path)
53
+ if self.fail_on is Action.ALLOW:
54
+ raise ValueError("fail_on must be block or warn")
55
+
56
+
57
+ class ScanStatistics(BaseModel):
58
+ model_config = ConfigDict(frozen=True, extra="forbid")
59
+
60
+ commits_scanned: int
61
+ violations: int # commits whose result is BLOCK
62
+ warnings: int # commits whose result is WARN
63
+ allowed: int
64
+ findings: int
65
+ detector_failures: int
66
+
67
+
68
+ class ScanMetadata(BaseModel):
69
+ model_config = ConfigDict(frozen=True, extra="forbid")
70
+
71
+ scan_id: str
72
+ tool_version: str
73
+ rules_version: str
74
+ policy_version: str
75
+ policy_source: str
76
+ provider: str
77
+ event: str
78
+ base_sha: str | None
79
+ head_sha: str | None
80
+
81
+
82
+ class ScanResult(BaseModel):
83
+ model_config = ConfigDict(frozen=True, extra="forbid")
84
+
85
+ report: ScanReport
86
+ plan: CIPlan
87
+ statistics: ScanStatistics
88
+ metadata: ScanMetadata
89
+ enforcement: EnforcementDecision
90
+ policies: tuple[Policy, ...] = () # effective policies, for reproducible history
91
+ #: Fields the repository configuration set per rule (before organization governance).
92
+ repository_overrides: dict[str, dict[str, str | bool]] = {}
93
+ effective: EffectivePolicy | None = None
94
+
95
+ @property
96
+ def action(self) -> Action:
97
+ return self.report.action
98
+
99
+
100
+ def statistics_for(report: ScanReport) -> ScanStatistics:
101
+ commits = report.commits
102
+ return ScanStatistics(
103
+ commits_scanned=len(commits),
104
+ violations=sum(1 for c in commits if c.action is Action.BLOCK),
105
+ warnings=sum(1 for c in commits if c.action is Action.WARN),
106
+ allowed=sum(1 for c in commits if c.action is Action.ALLOW),
107
+ findings=sum(len(c.findings) for c in commits),
108
+ detector_failures=sum(len(c.failures) for c in commits),
109
+ )
110
+
111
+
112
+ class ScanService:
113
+ def plan(self, request: ScanRequest) -> CIPlan:
114
+ return plan_ci(
115
+ request.repository,
116
+ request.context,
117
+ config_path=request.config_path,
118
+ max_commits=request.max_commits,
119
+ )
120
+
121
+ def execute(self, request: ScanRequest, plan: CIPlan) -> ScanResult:
122
+ run = execute_ci_plan(
123
+ request.repository,
124
+ request.context,
125
+ plan,
126
+ rules=request.rules,
127
+ mandatory=request.mandatory_policy,
128
+ governance=request.governance,
129
+ )
130
+ if request.rules is None:
131
+ rules_version = builtin_rules_fingerprint()
132
+ else:
133
+ rules_version = request.rules_version or "custom"
134
+ context = request.context
135
+ scan_id = fingerprint(
136
+ [
137
+ __version__,
138
+ rules_version,
139
+ run.policy_fingerprint,
140
+ context.provider.value,
141
+ context.event.value,
142
+ context.repository or "",
143
+ str(context.pull_request_number or ""),
144
+ plan.range.base or "",
145
+ plan.range.head or "",
146
+ ",".join(plan.range.exclude),
147
+ ]
148
+ )[:SCAN_ID_LENGTH]
149
+ metadata = ScanMetadata(
150
+ scan_id=scan_id,
151
+ tool_version=__version__,
152
+ rules_version=rules_version,
153
+ policy_version=run.policy_fingerprint,
154
+ policy_source=run.report.ci.policy_source if run.report.ci else "",
155
+ provider=context.provider.value,
156
+ event=context.event_name,
157
+ base_sha=plan.range.base,
158
+ head_sha=plan.range.head,
159
+ )
160
+ return ScanResult(
161
+ report=run.report,
162
+ plan=plan,
163
+ statistics=statistics_for(run.report),
164
+ metadata=metadata,
165
+ enforcement=EnforcementService(request.fail_on).decide(run.report),
166
+ policies=run.policies,
167
+ repository_overrides=run.repository_overrides,
168
+ effective=run.effective,
169
+ )
170
+
171
+ def run(self, request: ScanRequest) -> ScanResult:
172
+ return self.execute(request, self.plan(request))
@@ -0,0 +1 @@
1
+ """Low-level helpers (subprocess, filesystem, platform) with no domain knowledge."""