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,209 @@
1
+ """Evidence about how a repository enforces CommitGuard on GitHub.
2
+
3
+ The dashboard shows several independent signals and never merges them into
4
+ a claim they cannot support:
5
+
6
+ * **GitHub Actions** - whether a workflow on the default branch runs
7
+ CommitGuard. The workflow files are read with the installation token
8
+ (Contents: read) and inspected with the same static inspection
9
+ ``commitguard doctor`` uses; nothing is executed. Reading failures give
10
+ ``unknown``, never ``not_detected``.
11
+ * **Required check** - whether the default branch requires a CommitGuard
12
+ check before merging. Evidence comes from two read-only endpoints available
13
+ with the App's existing permissions:
14
+
15
+ - ``GET /repos/{owner}/{repo}/rules/branches/{branch}``: active rulesets and
16
+ their ``required_status_checks`` rules;
17
+ - ``GET /repos/{owner}/{repo}/branches/{branch}``: the ``protected`` flag
18
+ and, where GitHub includes it, the classic protection's required contexts.
19
+
20
+ ``required`` needs a CommitGuard check context (``commitguard-app``,
21
+ ``commitguard``) in one of those lists. ``not_required`` needs both
22
+ endpoints to answer and show no protection that could require it (branch
23
+ not protected and no required-status-check rule). Everything else - errors,
24
+ a protected branch whose required contexts are not visible without
25
+ Administration permission (which CommitGuard does not request) - is
26
+ ``unknown``.
27
+ * **Merge queue** - ``enabled`` when an active ruleset on the default branch
28
+ has a ``merge_queue`` rule; ``not_enabled`` when both endpoints answer, no
29
+ ruleset requires a merge queue and the branch has no classic protection; a
30
+ protected branch is ``unknown``, because classic branch protection settings
31
+ (which can also require a merge queue) are not visible with the App's
32
+ permissions.
33
+ * **Local hooks** cannot be verified by a server and are always reported as
34
+ such.
35
+ """
36
+
37
+ from collections.abc import Callable
38
+ from dataclasses import dataclass
39
+ from datetime import UTC, datetime
40
+ from pathlib import Path
41
+
42
+ from commitguard.github.checks import APP_CHECK_NAME
43
+ from commitguard.github.client import GitHubClient
44
+ from commitguard.github.errors import GitHubAPIError, GitHubNotFoundError
45
+ from commitguard.github.identifiers import RepositoryRef
46
+ from commitguard.github.workflow import CHECK_NAME as ACTION_CHECK_NAME
47
+ from commitguard.github.workflow import WORKFLOWS_DIR, inspect_workflow_text
48
+ from commitguard.observability.logging import get_logger
49
+ from commitguard.security.secrets import Secret
50
+
51
+ log = get_logger(__name__)
52
+
53
+ COMMITGUARD_CHECK_CONTEXTS = (APP_CHECK_NAME, ACTION_CHECK_NAME)
54
+ MAX_WORKFLOW_FILES = 50
55
+
56
+
57
+ @dataclass(frozen=True, slots=True)
58
+ class EnforcementEvidence:
59
+ checked_at: datetime
60
+ branch: str | None
61
+ actions: str # detected | not_detected | unknown
62
+ actions_detail: str
63
+ branch_protection: str # required | not_required | unknown
64
+ required_checks: tuple[str, ...]
65
+ branch_protection_detail: str
66
+ merge_queue: str = "unknown" # enabled | not_enabled | unknown
67
+ merge_queue_detail: str = "Not checked yet."
68
+
69
+
70
+ class EnforcementProbe:
71
+ def __init__(
72
+ self,
73
+ client: GitHubClient,
74
+ *,
75
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
76
+ ) -> None:
77
+ self._client = client
78
+ self._now = now
79
+
80
+ def probe(
81
+ self, token: Secret, repository: RepositoryRef, default_branch: str | None
82
+ ) -> EnforcementEvidence:
83
+ now = self._now()
84
+ if not default_branch:
85
+ return EnforcementEvidence(
86
+ now,
87
+ None,
88
+ "unknown",
89
+ "the repository has no default branch",
90
+ "unknown",
91
+ (),
92
+ "the repository has no default branch",
93
+ )
94
+ actions, actions_detail = self._actions(token, repository, default_branch)
95
+ protection, checks, protection_detail, queue, queue_detail = self._required_check(
96
+ token, repository, default_branch
97
+ )
98
+ return EnforcementEvidence(
99
+ now,
100
+ default_branch,
101
+ actions,
102
+ actions_detail,
103
+ protection,
104
+ checks,
105
+ protection_detail,
106
+ queue,
107
+ queue_detail,
108
+ )
109
+
110
+ def _actions(self, token: Secret, repository: RepositoryRef, branch: str) -> tuple[str, str]:
111
+ directory = str(WORKFLOWS_DIR)
112
+ try:
113
+ entries = self._client.list_directory(token, repository, directory, branch)
114
+ except GitHubNotFoundError:
115
+ return "not_detected", f"no {directory} directory on {branch}"
116
+ except GitHubAPIError as exc:
117
+ return "unknown", f"workflows could not be read ({exc.category.value})"
118
+ workflows = [e for e in entries if e.type == "file" and e.name.endswith((".yml", ".yaml"))][
119
+ :MAX_WORKFLOW_FILES
120
+ ]
121
+ for entry in workflows:
122
+ try:
123
+ text = self._client.get_file_text(token, repository, entry.path, branch)
124
+ except GitHubAPIError as exc:
125
+ return "unknown", f"{entry.name} could not be read ({exc.category.value})"
126
+ if text is None:
127
+ continue
128
+ inspection = inspect_workflow_text(Path(entry.path), text)
129
+ if inspection is not None and inspection.check_names:
130
+ names = ", ".join(inspection.check_names)
131
+ return "detected", f"{entry.path} runs CommitGuard (check: {names})"
132
+ return "not_detected", f"no workflow on {branch} runs CommitGuard"
133
+
134
+ def _required_check(
135
+ self, token: Secret, repository: RepositoryRef, branch: str
136
+ ) -> tuple[str, tuple[str, ...], str, str, str]:
137
+ rules_ok = branch_ok = False
138
+ required: set[str] = set()
139
+ has_status_rule = False
140
+ has_merge_queue = False
141
+ protected = None
142
+ try:
143
+ rules = self._client.get_branch_rules(token, repository, branch)
144
+ rules_ok = True
145
+ for rule in rules:
146
+ if rule.type == "required_status_checks":
147
+ has_status_rule = True
148
+ required.update(rule.required_contexts)
149
+ elif rule.type == "merge_queue":
150
+ has_merge_queue = True
151
+ except GitHubAPIError as exc:
152
+ log.info("branch_rules_unavailable", category=exc.category.value)
153
+ try:
154
+ info = self._client.get_branch(token, repository, branch)
155
+ branch_ok = True
156
+ protected = info.protected
157
+ required.update(info.required_contexts)
158
+ except GitHubAPIError as exc:
159
+ log.info("branch_unavailable", category=exc.category.value)
160
+ if has_merge_queue:
161
+ queue, queue_detail = "enabled", f"a ruleset on {branch} requires a merge queue"
162
+ elif rules_ok and branch_ok and not protected:
163
+ queue, queue_detail = (
164
+ "not_enabled",
165
+ f"no ruleset requires a merge queue on {branch}, and it has no branch protection",
166
+ )
167
+ elif branch_ok and protected:
168
+ queue, queue_detail = (
169
+ "unknown",
170
+ f"{branch} is protected; whether classic protection requires a merge queue is "
171
+ "not visible with the App's permissions",
172
+ )
173
+ else:
174
+ queue, queue_detail = "unknown", "merge queue settings could not be read"
175
+ ours = tuple(sorted(c for c in required if c in COMMITGUARD_CHECK_CONTEXTS))
176
+ if ours:
177
+ return (
178
+ "required",
179
+ ours,
180
+ f"{branch} requires {', '.join(ours)} before merging",
181
+ queue,
182
+ queue_detail,
183
+ )
184
+ if rules_ok and branch_ok and not protected and not has_status_rule:
185
+ return (
186
+ "not_required",
187
+ (),
188
+ f"{branch} is not protected and no ruleset requires status checks",
189
+ queue,
190
+ queue_detail,
191
+ )
192
+ if rules_ok and branch_ok and has_status_rule and not protected:
193
+ return (
194
+ "not_required",
195
+ (),
196
+ f"rulesets on {branch} require status checks, but not a CommitGuard check",
197
+ queue,
198
+ queue_detail,
199
+ )
200
+ if branch_ok and protected:
201
+ return (
202
+ "unknown",
203
+ (),
204
+ f"{branch} is protected, but its required checks are not visible with the "
205
+ "App's permissions",
206
+ queue,
207
+ queue_detail,
208
+ )
209
+ return "unknown", (), "branch protection could not be read", queue, queue_detail
@@ -0,0 +1,129 @@
1
+ """Errors of the GitHub App integration.
2
+
3
+ Messages are safe to log and to show in a Check Run: they never contain
4
+ tokens, JWTs, the private key, the webhook secret or request headers, and
5
+ GitHub response text is sanitised, redacted and truncated before it is used.
6
+ """
7
+
8
+ from enum import StrEnum
9
+
10
+ from commitguard.exceptions.base import CommitGuardError
11
+ from commitguard.exceptions.configuration import ConfigurationError
12
+ from commitguard.security.sanitization import sanitize_for_terminal
13
+ from commitguard.security.secrets import redact
14
+
15
+
16
+ def safe_text(text: str, limit: int = 300) -> str:
17
+ """Untrusted or credential-adjacent text, made safe for messages."""
18
+ return sanitize_for_terminal(redact(text), max_length=limit)
19
+
20
+
21
+ class GitHubAppError(CommitGuardError):
22
+ """Base class for GitHub App integration errors."""
23
+
24
+
25
+ class AppConfigurationError(ConfigurationError):
26
+ """The App's environment configuration is missing or invalid (names only, never values)."""
27
+
28
+
29
+ class AuthenticationError(GitHubAppError):
30
+ """App credentials are invalid, or GitHub rejected the App / installation token."""
31
+
32
+
33
+ class AuthorizationError(GitHubAppError):
34
+ """The installation is not allowed to access the repository (or no longer exists)."""
35
+
36
+
37
+ class InsufficientPermissionsError(AuthorizationError):
38
+ def __init__(self, missing: dict[str, str]) -> None:
39
+ self.missing = dict(sorted(missing.items()))
40
+ wanted = ", ".join(f"{name}: {level}" for name, level in self.missing.items())
41
+ super().__init__(f"GitHub App installation is missing required permissions ({wanted})")
42
+
43
+
44
+ class WebhookValidationError(GitHubAppError):
45
+ """A webhook delivery was rejected. ``status`` is the HTTP status to return."""
46
+
47
+ def __init__(self, message: str, *, status: int = 400) -> None:
48
+ self.status = status
49
+ super().__init__(message)
50
+
51
+
52
+ class GitHubErrorCategory(StrEnum):
53
+ UNAUTHORIZED = "unauthorized" # 401
54
+ FORBIDDEN = "forbidden" # 403 (not rate limiting)
55
+ NOT_FOUND = "not_found" # 404
56
+ CONFLICT = "conflict" # 409
57
+ VALIDATION = "validation" # 422
58
+ RATE_LIMITED = "rate_limited" # 429 / 403 rate limit
59
+ SERVER_ERROR = "server_error" # 5xx
60
+ UNAVAILABLE = "unavailable" # timeout / network failure
61
+ UNEXPECTED = "unexpected" # redirects, malformed responses, other statuses
62
+
63
+
64
+ class GitHubAPIError(GitHubAppError):
65
+ """A GitHub REST API call failed after bounded retries."""
66
+
67
+ category = GitHubErrorCategory.UNEXPECTED
68
+
69
+ def __init__(
70
+ self,
71
+ operation: str,
72
+ *,
73
+ status: int | None = None,
74
+ detail: str = "",
75
+ request_id: str | None = None,
76
+ ) -> None:
77
+ self.operation = operation
78
+ self.status = status
79
+ self.request_id = safe_text(request_id, 80) if request_id else None
80
+ self.detail = safe_text(detail) if detail else ""
81
+ status_text = f"HTTP {status}" if status is not None else "no response"
82
+ message = f"GitHub API {operation} failed: {status_text} ({self.category.value})"
83
+ if self.detail:
84
+ message += f": {self.detail}"
85
+ super().__init__(message)
86
+
87
+
88
+ class GitHubUnauthorizedError(GitHubAPIError, AuthenticationError):
89
+ category = GitHubErrorCategory.UNAUTHORIZED
90
+
91
+
92
+ class GitHubForbiddenError(GitHubAPIError, AuthorizationError):
93
+ category = GitHubErrorCategory.FORBIDDEN
94
+
95
+
96
+ class GitHubNotFoundError(GitHubAPIError):
97
+ category = GitHubErrorCategory.NOT_FOUND
98
+
99
+
100
+ class GitHubConflictError(GitHubAPIError):
101
+ category = GitHubErrorCategory.CONFLICT
102
+
103
+
104
+ class GitHubValidationError(GitHubAPIError):
105
+ category = GitHubErrorCategory.VALIDATION
106
+
107
+
108
+ class GitHubRateLimitError(GitHubAPIError):
109
+ category = GitHubErrorCategory.RATE_LIMITED
110
+
111
+ def __init__(
112
+ self,
113
+ operation: str,
114
+ *,
115
+ retry_after: float | None = None,
116
+ status: int | None = None,
117
+ detail: str = "",
118
+ request_id: str | None = None,
119
+ ) -> None:
120
+ self.retry_after = retry_after
121
+ super().__init__(operation, status=status, detail=detail, request_id=request_id)
122
+
123
+
124
+ class GitHubServerError(GitHubAPIError):
125
+ category = GitHubErrorCategory.SERVER_ERROR
126
+
127
+
128
+ class GitHubUnavailableError(GitHubAPIError):
129
+ category = GitHubErrorCategory.UNAVAILABLE