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,95 @@
1
+ """AuditService: create audit events with correlation IDs and record them."""
2
+
3
+ from collections.abc import Callable, Mapping, Sequence
4
+ from datetime import UTC, datetime
5
+
6
+ from commitguard.audit.logger import AuditLogger
7
+ from commitguard.audit.models import SYSTEM_ACTOR, Actor, AuditEvent, AuditEventType, AuditValue
8
+ from commitguard.audit.storage import AuditStorage
9
+ from commitguard.core.decision import Action
10
+ from commitguard.observability.logging import current_correlation
11
+
12
+
13
+ class AuditService:
14
+ def __init__(
15
+ self,
16
+ storages: Sequence[AuditStorage] = (),
17
+ *,
18
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
19
+ ) -> None:
20
+ self._logger = AuditLogger(storages)
21
+ self._now = now
22
+
23
+ def record(
24
+ self,
25
+ event_type: AuditEventType,
26
+ *,
27
+ actor: Actor = SYSTEM_ACTOR,
28
+ account_id: int | None = None,
29
+ installation_id: int | None = None,
30
+ repository_id: int | None = None,
31
+ repository: str | None = None,
32
+ head_sha: str | None = None,
33
+ action: Action | None = None,
34
+ extra: Mapping[str, AuditValue] | None = None,
35
+ **data: AuditValue,
36
+ ) -> AuditEvent:
37
+ """Record an event. ``extra`` carries data built elsewhere (merged with ``data``)."""
38
+ event = self.build(
39
+ event_type,
40
+ actor=actor,
41
+ account_id=account_id,
42
+ installation_id=installation_id,
43
+ repository_id=repository_id,
44
+ repository=repository,
45
+ head_sha=head_sha,
46
+ action=action,
47
+ extra={**(extra or {}), **data},
48
+ )
49
+ self._logger.record(event)
50
+ return event
51
+
52
+ def build(
53
+ self,
54
+ event_type: AuditEventType,
55
+ *,
56
+ actor: Actor = SYSTEM_ACTOR,
57
+ account_id: int | None = None,
58
+ installation_id: int | None = None,
59
+ repository_id: int | None = None,
60
+ repository: str | None = None,
61
+ head_sha: str | None = None,
62
+ action: Action | None = None,
63
+ extra: Mapping[str, AuditValue] | None = None,
64
+ **data: AuditValue,
65
+ ) -> AuditEvent:
66
+ """Create an event without storing it (for writes inside a larger transaction)."""
67
+ data = {**(extra or {}), **data}
68
+ correlation = current_correlation()
69
+
70
+ def _str(key: str) -> str | None:
71
+ value = correlation.get(key)
72
+ return str(value) if value is not None else None
73
+
74
+ return AuditEvent(
75
+ type=event_type,
76
+ occurred_at=self._now(),
77
+ delivery_id=_str("delivery_id"),
78
+ request_id=_str("request_id"),
79
+ job_id=_str("job_id"),
80
+ scan_id=_str("scan_id"),
81
+ actor_type=actor.type,
82
+ actor_id=actor.id,
83
+ actor_login=actor.login,
84
+ account_id=account_id,
85
+ installation_id=installation_id,
86
+ repository_id=repository_id,
87
+ repository=repository,
88
+ head_sha=head_sha,
89
+ action=action,
90
+ data=data,
91
+ )
92
+
93
+ def log_stored(self, event: AuditEvent) -> None:
94
+ """Emit the log line for an event another component stored transactionally."""
95
+ self._logger.log(event)
@@ -0,0 +1,383 @@
1
+ """CI enforcement service: the server-side counterpart of the pre-push hook.
2
+
3
+ Given a provider-neutral :class:`~commitguard.ci.context.CIContext` it decides
4
+
5
+ 1. **which commits** the event introduces (a :class:`CommitRange`), and
6
+ 2. **which policy** evaluates them (a trusted :class:`PolicySource`),
7
+
8
+ then runs the same :class:`~commitguard.services.analysis.Analyzer` as
9
+ ``commitguard scan`` and the Git hooks. There is no CI-specific detection.
10
+
11
+ Trust model (see docs/github-enforcement.md):
12
+
13
+ ============================== ========================== =========================
14
+ Event Commits analysed Policy read from
15
+ ============================== ========================== =========================
16
+ pull request / merge group ``head ^base`` base commit's tree
17
+ push, ``before`` known ``after ^before`` ``before`` commit's tree
18
+ push, new ref (not default) ``after ^<default branch>`` default branch tip's tree
19
+ push, new default branch / no ``after`` (bounded) built-in defaults
20
+ trusted commit available
21
+ push, ref deleted nothing -
22
+ ============================== ========================== =========================
23
+
24
+ The evaluated commits' own ``.commitguard.yaml`` is never applied; a change to
25
+ it is reported as a notice and takes effect once it is on the trusted branch.
26
+ A change that would *weaken* a policy (disable it or lower its action) is
27
+ reported as a security policy modification. Detection rules always come from
28
+ the installed CommitGuard package. A central service may add a mandatory
29
+ policy (:mod:`commitguard.policies.mandatory`) that no repository can weaken,
30
+ or - for the GitHub App - the complete organization governance inputs
31
+ (:mod:`commitguard.policies.governance`): policy layers, approved exceptions and
32
+ monitor mode, resolved together with the trusted repository configuration.
33
+
34
+ Planning (:func:`plan_ci`) and execution (:func:`execute_ci_plan`) are separate
35
+ so a service can report the commit count before analysis starts.
36
+ """
37
+
38
+ from collections.abc import Sequence
39
+
40
+ from pydantic import BaseModel, ConfigDict
41
+
42
+ from commitguard.ci.context import CIContext, CIEventKind
43
+ from commitguard.config.schema import CommitGuardConfig
44
+ from commitguard.config.sources import (
45
+ MandatoryPolicy,
46
+ PolicySource,
47
+ PolicySourceKind,
48
+ config_differs,
49
+ load_config_at_revision,
50
+ load_policy_source,
51
+ )
52
+ from commitguard.core.context import ScanTrigger
53
+ from commitguard.exceptions.configuration import ConfigurationError
54
+ from commitguard.git.ranges import CommitRange, require_commit, resolve_commit_range
55
+ from commitguard.git.repository import Repository
56
+ from commitguard.policies.governance import EffectivePolicy, GovernanceInputs, resolve_policy
57
+ from commitguard.policies.loader import build_policy_set
58
+ from commitguard.policies.mandatory import apply_mandatory_policies
59
+ from commitguard.policies.model import Policy, PolicySet
60
+ from commitguard.rules.matcher import CompiledRules
61
+ from commitguard.security.hashing import fingerprint
62
+ from commitguard.services.analysis import Analyzer, build_report
63
+ from commitguard.services.reports import CIReport, ScanReport
64
+
65
+ DEFAULT_CI_MAX_COMMITS = 10_000
66
+
67
+
68
+ class CIPlan(BaseModel):
69
+ model_config = ConfigDict(frozen=True, extra="forbid")
70
+
71
+ range: CommitRange
72
+ policy_source: PolicySource
73
+ config_changes: tuple[str, ...] = ()
74
+ policy_weakenings: tuple[str, ...] = ()
75
+ notices: tuple[str, ...] = ()
76
+
77
+
78
+ class CIRun(BaseModel):
79
+ model_config = ConfigDict(frozen=True, extra="forbid")
80
+
81
+ context: CIContext
82
+ plan: CIPlan
83
+ report: ScanReport
84
+ policy_fingerprint: str # effective policies (trusted config + mandatory floor)
85
+ policies: tuple[Policy, ...] = () # the effective policies that evaluated the commits
86
+ #: The repository configuration's own overrides (rule -> fields it set), kept so a
87
+ #: policy simulation can re-resolve historical scans under a draft policy.
88
+ repository_overrides: dict[str, dict[str, str | bool]] = {}
89
+ effective: EffectivePolicy | None = None # provenance, when governance applied
90
+
91
+
92
+ def repository_overrides(
93
+ configs: Sequence[CommitGuardConfig],
94
+ ) -> dict[str, dict[str, str | bool]]:
95
+ """The merged per-rule fields the configuration layers set (later layers win)."""
96
+ merged: dict[str, dict[str, str | bool]] = {}
97
+ for config in configs:
98
+ for rule, override in config.policies.items():
99
+ for key, value in override.model_dump(mode="json", exclude_unset=True).items():
100
+ merged.setdefault(rule, {})[key] = value
101
+ return dict(sorted(merged.items()))
102
+
103
+
104
+ def policy_set_fingerprint(policies: PolicySet) -> str:
105
+ parts: list[str] = []
106
+ for policy in policies.values():
107
+ parts += [policy.id, str(policy.enabled), policy.action.value]
108
+ return fingerprint(parts)
109
+
110
+
111
+ def policy_weakenings(
112
+ repository: Repository, trusted: str, head: str, *, config_path: str | None = None
113
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
114
+ """Policies the evaluated commits' configuration would weaken, plus notices.
115
+
116
+ Only used for reporting: the evaluated commits' configuration is never applied.
117
+ """
118
+ try:
119
+ before = build_policy_set(
120
+ *load_config_at_revision(repository, trusted, config_path=config_path).configs
121
+ )
122
+ except ConfigurationError:
123
+ return (), () # the trusted configuration itself is invalid: the scan fails later
124
+ try:
125
+ after = build_policy_set(
126
+ *load_config_at_revision(repository, head, config_path=config_path).configs
127
+ )
128
+ except ConfigurationError:
129
+ return (), (
130
+ "the evaluated commits contain an invalid or missing CommitGuard configuration; "
131
+ "it was not applied",
132
+ )
133
+ weakened = []
134
+ for policy_id, old in before.items():
135
+ new = after[policy_id]
136
+ if old.enabled and not new.enabled:
137
+ weakened.append(f"{policy_id}: {old.action.value} -> disabled")
138
+ elif old.enabled and new.action.rank < old.action.rank:
139
+ weakened.append(f"{policy_id}: {old.action.value} -> {new.action.value}")
140
+ return tuple(weakened), ()
141
+
142
+
143
+ def _config_notices(
144
+ repository: Repository,
145
+ trusted: str,
146
+ head: str,
147
+ changes: tuple[str, ...],
148
+ label: str,
149
+ config_path: str | None,
150
+ ) -> tuple[tuple[str, ...], list[str]]:
151
+ if not changes:
152
+ return (), []
153
+ notices = [
154
+ f"{', '.join(changes)} is changed by the evaluated commits; the policy from the "
155
+ f"{label} was used. Policy changes take effect after they reach the trusted branch."
156
+ ]
157
+ weakened, extra = policy_weakenings(repository, trusted, head, config_path=config_path)
158
+ notices.extend(extra)
159
+ if weakened:
160
+ notices.append(
161
+ "Security policy modification detected: the evaluated commits attempt to weaken "
162
+ f"an existing CommitGuard policy ({'; '.join(weakened)}). The trusted policy was "
163
+ "used; additional authorization may be required."
164
+ )
165
+ return weakened, notices
166
+
167
+
168
+ def _default_branch_tip(repository: Repository, context: CIContext) -> str | None:
169
+ if not context.default_branch:
170
+ return None
171
+ for ref in (
172
+ f"refs/remotes/origin/{context.default_branch}",
173
+ f"refs/heads/{context.default_branch}",
174
+ ):
175
+ tip = repository.ref_commit(ref)
176
+ if tip is not None:
177
+ return tip
178
+ return None
179
+
180
+
181
+ def plan_ci(
182
+ repository: Repository,
183
+ context: CIContext,
184
+ *,
185
+ config_path: str | None = None,
186
+ max_commits: int = DEFAULT_CI_MAX_COMMITS,
187
+ ) -> CIPlan:
188
+ notices: list[str] = []
189
+
190
+ if context.event in (CIEventKind.PULL_REQUEST, CIEventKind.MERGE_GROUP):
191
+ if context.base_sha is None or context.head_sha is None: # guaranteed by CIContext
192
+ raise ValueError("pull request context without base and head commits")
193
+ base = require_commit(repository, context.base_sha, role="base")
194
+ head = require_commit(repository, context.head_sha, role="head")
195
+ commit_range = resolve_commit_range(
196
+ repository, head, [base], base=base, max_count=max_commits
197
+ )
198
+ label = (
199
+ "pull request base" if context.event is CIEventKind.PULL_REQUEST else "merge queue base"
200
+ )
201
+ source = PolicySource(
202
+ kind=PolicySourceKind.REVISION,
203
+ revision=base,
204
+ description=label,
205
+ config_path=config_path,
206
+ )
207
+ changes = tuple(config_differs(repository, base, head))
208
+ weakened, config_notices = _config_notices(
209
+ repository, base, head, changes, label, config_path
210
+ )
211
+ notices.extend(config_notices)
212
+ return CIPlan(
213
+ range=commit_range,
214
+ policy_source=source,
215
+ config_changes=changes,
216
+ policy_weakenings=weakened,
217
+ notices=tuple(notices),
218
+ )
219
+
220
+ # push
221
+ if context.ref_deleted or context.after_sha is None:
222
+ return CIPlan(
223
+ range=CommitRange(),
224
+ policy_source=PolicySource(kind=PolicySourceKind.BUILTIN, description="not needed"),
225
+ notices=("ref deleted: no commits to analyse",),
226
+ )
227
+ if not repository.object_exists(context.after_sha):
228
+ require_commit(repository, context.after_sha, role="pushed") # raises a clear error
229
+ pushed = repository.peel_to_commit(context.after_sha)
230
+ if pushed is None:
231
+ return CIPlan(
232
+ range=CommitRange(),
233
+ policy_source=PolicySource(kind=PolicySourceKind.BUILTIN, description="not needed"),
234
+ notices=("pushed object is not a commit (e.g. a tag of a tree): nothing to analyse",),
235
+ )
236
+
237
+ before = repository.peel_to_commit(context.before_sha) if context.before_sha else None
238
+ if context.before_sha and before is None:
239
+ notices.append(
240
+ f"previous commit {context.before_sha[:12]} is not in this clone (force push?); "
241
+ "falling back to the default branch"
242
+ )
243
+ if before is not None:
244
+ exclude = [before]
245
+ source = PolicySource(
246
+ kind=PolicySourceKind.REVISION,
247
+ revision=before,
248
+ description="commit before the push",
249
+ config_path=config_path,
250
+ )
251
+ changes = tuple(config_differs(repository, before, pushed))
252
+ else:
253
+ is_default = bool(context.default_branch) and context.ref == (
254
+ f"refs/heads/{context.default_branch}"
255
+ )
256
+ tip = None if is_default else _default_branch_tip(repository, context)
257
+ if tip is not None:
258
+ exclude = [tip]
259
+ source = PolicySource(
260
+ kind=PolicySourceKind.REVISION,
261
+ revision=tip,
262
+ description="default branch",
263
+ config_path=config_path,
264
+ )
265
+ changes = tuple(config_differs(repository, tip, pushed))
266
+ else:
267
+ exclude = []
268
+ source = PolicySource(
269
+ kind=PolicySourceKind.BUILTIN,
270
+ description="built-in defaults (no trusted commit available)",
271
+ config_path=config_path,
272
+ )
273
+ changes = ()
274
+ notices.append(
275
+ "no trusted commit to read policy from or to limit the range; analysing all "
276
+ "commits reachable from the pushed commit with built-in default policies"
277
+ )
278
+ push_weakened: tuple[str, ...] = ()
279
+ if changes and source.revision is not None:
280
+ push_weakened, push_notices = _config_notices(
281
+ repository, source.revision, pushed, changes, source.description, config_path
282
+ )
283
+ notices.extend(push_notices)
284
+ commit_range = resolve_commit_range(
285
+ repository, pushed, exclude, base=exclude[0] if exclude else None, max_count=max_commits
286
+ )
287
+ return CIPlan(
288
+ range=commit_range,
289
+ policy_source=source,
290
+ config_changes=changes,
291
+ policy_weakenings=push_weakened,
292
+ notices=tuple(notices),
293
+ )
294
+
295
+
296
+ def execute_ci_plan(
297
+ repository: Repository,
298
+ context: CIContext,
299
+ plan: CIPlan,
300
+ *,
301
+ rules: CompiledRules | None = None,
302
+ mandatory: MandatoryPolicy | None = None,
303
+ governance: GovernanceInputs | None = None,
304
+ ) -> CIRun:
305
+ """Analyse the planned commits with the planned (trusted) policy.
306
+
307
+ ``governance`` replaces ``mandatory`` when given (its service layer carries
308
+ the same floor): the effective policy is resolved from the governance inputs
309
+ and the trusted repository configuration.
310
+ """
311
+ loaded = load_policy_source(repository, plan.policy_source)
312
+ repository_policies = build_policy_set(*loaded.configs)
313
+ policies = repository_policies
314
+ extra_sources: tuple[str, ...] = ()
315
+ effective: EffectivePolicy | None = None
316
+ if governance is not None:
317
+ effective = resolve_policy(governance, loaded.configs)
318
+ policies = effective.policy_set()
319
+ extra_sources = (f"organization governance: {governance.describe()}",)
320
+ elif mandatory is not None:
321
+ policies = apply_mandatory_policies(policies, mandatory.config)
322
+ extra_sources = (f"mandatory: {mandatory.description}",)
323
+ analyzer = Analyzer.create(policies, rules)
324
+ # Commits are read and analysed in batches; only the compact reports are kept.
325
+ reports = [
326
+ analyzer.analyze(commit, ScanTrigger.CI)
327
+ for commit in repository.iter_commits(plan.range.commits)
328
+ ]
329
+ policy_source = str(plan.policy_source)
330
+ if governance is not None and not governance.empty:
331
+ policy_source += f" + {governance.describe()}"
332
+ elif mandatory is not None and governance is None:
333
+ policy_source += f" + mandatory policy ({mandatory.description})"
334
+ ci_report = CIReport(
335
+ provider=context.provider.value,
336
+ event=context.event_name,
337
+ repository=context.repository,
338
+ ref=context.ref,
339
+ pull_request_number=context.pull_request_number,
340
+ from_fork=context.from_fork,
341
+ base_sha=plan.range.base,
342
+ head_sha=plan.range.head,
343
+ policy_source=policy_source,
344
+ config_changes=plan.config_changes,
345
+ policy_weakenings=plan.policy_weakenings,
346
+ notices=plan.notices,
347
+ )
348
+ target = (
349
+ f"{plan.range.base[:12]}..{plan.range.head[:12]}"
350
+ if plan.range.base and plan.range.head
351
+ else (plan.range.head[:12] if plan.range.head else "nothing")
352
+ )
353
+ report = build_report(
354
+ reports,
355
+ repository=None,
356
+ target=target,
357
+ trigger=ScanTrigger.CI,
358
+ config=loaded,
359
+ ci=ci_report,
360
+ extra_config_sources=extra_sources,
361
+ )
362
+ return CIRun(
363
+ context=context,
364
+ plan=plan,
365
+ report=report,
366
+ policy_fingerprint=policy_set_fingerprint(policies),
367
+ policies=tuple(policies.values()),
368
+ repository_overrides=repository_overrides(loaded.configs),
369
+ effective=effective,
370
+ )
371
+
372
+
373
+ def run_ci(
374
+ repository: Repository,
375
+ context: CIContext,
376
+ *,
377
+ config_path: str | None = None,
378
+ max_commits: int = DEFAULT_CI_MAX_COMMITS,
379
+ rules: CompiledRules | None = None,
380
+ mandatory: MandatoryPolicy | None = None,
381
+ ) -> CIRun:
382
+ plan = plan_ci(repository, context, config_path=config_path, max_commits=max_commits)
383
+ return execute_ci_plan(repository, context, plan, rules=rules, mandatory=mandatory)
@@ -0,0 +1,102 @@
1
+ """Enforcement: translate a policy result into what an entry point does.
2
+
3
+ The policy engine decides ALLOW / WARN / BLOCK. This service turns that
4
+ decision - or the failure to reach one - into an :class:`EnforcementDecision`.
5
+ ``commitguard ci github`` and the GitHub App use it directly; the Git hooks
6
+ (:mod:`commitguard.services.hooks`) follow the same exit-code contract:
7
+
8
+ ===================== ============== ================== ================
9
+ State CLI / Action Git hook GitHub Check
10
+ ===================== ============== ================== ================
11
+ passed exit 0 commit/push allowed ``success``
12
+ passed_with_warnings exit 0 allowed ``success`` (+ warnings)
13
+ blocked exit 1 rejected ``failure``
14
+ error exit 2 rejected ``failure`` / ``timed_out``
15
+ ===================== ============== ================== ================
16
+
17
+ ``blocked`` (the scan worked and found a violation) and ``error`` (the scan
18
+ could not be completed) are kept apart for audit and reporting even though
19
+ both fail closed.
20
+ """
21
+
22
+ from enum import StrEnum
23
+
24
+ from pydantic import BaseModel, ConfigDict
25
+
26
+ from commitguard.core.decision import Action
27
+ from commitguard.services.reports import ScanReport
28
+
29
+
30
+ class EnforcementState(StrEnum):
31
+ PASSED = "passed"
32
+ PASSED_WITH_WARNINGS = "passed_with_warnings"
33
+ BLOCKED = "blocked"
34
+ ERROR = "error"
35
+
36
+
37
+ class FailureKind(StrEnum):
38
+ CONFIGURATION = "configuration" # invalid trusted/mandatory policy or rules
39
+ AUTHORIZATION = "authorization" # the integration may not access the repository
40
+ INFRASTRUCTURE = "infrastructure" # Git, storage, GitHub API unavailable
41
+ TIMEOUT = "timeout"
42
+ INTERNAL = "internal" # unexpected error
43
+
44
+
45
+ class EnforcementDecision(BaseModel):
46
+ model_config = ConfigDict(frozen=True, extra="forbid")
47
+
48
+ state: EnforcementState
49
+ action: Action | None = None
50
+ failure: FailureKind | None = None
51
+ reason: str
52
+
53
+ @property
54
+ def allowed(self) -> bool:
55
+ return self.state in (EnforcementState.PASSED, EnforcementState.PASSED_WITH_WARNINGS)
56
+
57
+ @property
58
+ def exit_code(self) -> int:
59
+ """0 allowed, 1 blocked by policy, 2 could not be verified (stable CLI contract)."""
60
+ return {
61
+ EnforcementState.PASSED: 0,
62
+ EnforcementState.PASSED_WITH_WARNINGS: 0,
63
+ EnforcementState.BLOCKED: 1,
64
+ EnforcementState.ERROR: 2,
65
+ }[self.state]
66
+
67
+ @property
68
+ def check_conclusion(self) -> str:
69
+ if self.allowed:
70
+ return "success"
71
+ if self.failure is FailureKind.TIMEOUT:
72
+ return "timed_out"
73
+ return "failure"
74
+
75
+
76
+ class EnforcementService:
77
+ def __init__(self, fail_on: Action = Action.BLOCK) -> None:
78
+ if fail_on is Action.ALLOW:
79
+ raise ValueError("fail_on must be block or warn")
80
+ self.fail_on = fail_on
81
+
82
+ def decide(self, report: ScanReport) -> EnforcementDecision:
83
+ action = report.action
84
+ if action.rank >= self.fail_on.rank:
85
+ return EnforcementDecision(
86
+ state=EnforcementState.BLOCKED,
87
+ action=action,
88
+ reason=f"policy result {action.value} is at or above fail-on {self.fail_on.value}",
89
+ )
90
+ if action is Action.WARN:
91
+ return EnforcementDecision(
92
+ state=EnforcementState.PASSED_WITH_WARNINGS,
93
+ action=action,
94
+ reason="warnings only",
95
+ )
96
+ return EnforcementDecision(
97
+ state=EnforcementState.PASSED, action=action, reason="all policies passed"
98
+ )
99
+
100
+ @staticmethod
101
+ def failed(kind: FailureKind, reason: str) -> EnforcementDecision:
102
+ return EnforcementDecision(state=EnforcementState.ERROR, failure=kind, reason=reason)