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,217 @@
1
+ """GitHub App authentication.
2
+
3
+ ::
4
+
5
+ App ID + private key --(RS256 JWT, 9 min)--> POST /app/installations/{id}/access_tokens
6
+ (repository_ids=[one repo], least-privilege
7
+ permissions)
8
+ <-- installation token (expires after ~1 hour)
9
+
10
+ * The private key is parsed once at start-up; a malformed, encrypted, non-RSA
11
+ or short key fails closed with a message that never contains key material.
12
+ * JWTs and installation tokens are :class:`~commitguard.security.secrets.Secret`
13
+ values registered for redaction; they are kept in memory only, never
14
+ persisted, and dropped when they are near expiry, when GitHub rejects them,
15
+ or when the installation or repository is removed.
16
+ * Every installation token is down-scoped to the single repository being
17
+ scanned and to :data:`~commitguard.github.permissions.REQUIRED_PERMISSIONS`;
18
+ GitHub refuses to mint it if the installation cannot access that repository,
19
+ which makes the token request itself an authorization check.
20
+ """
21
+
22
+ import base64
23
+ import json
24
+ import threading
25
+ import time
26
+ from collections.abc import Callable, Mapping
27
+ from dataclasses import dataclass
28
+ from datetime import UTC, datetime, timedelta
29
+ from typing import NoReturn
30
+
31
+ from cryptography.hazmat.primitives import hashes, serialization
32
+ from cryptography.hazmat.primitives.asymmetric import padding, rsa
33
+
34
+ from commitguard.github.client import GitHubClient, InstallationTokenGrant
35
+ from commitguard.github.errors import (
36
+ AuthenticationError,
37
+ AuthorizationError,
38
+ GitHubForbiddenError,
39
+ GitHubNotFoundError,
40
+ GitHubUnauthorizedError,
41
+ GitHubValidationError,
42
+ InsufficientPermissionsError,
43
+ )
44
+ from commitguard.github.permissions import REQUIRED_PERMISSIONS, missing_permissions
45
+ from commitguard.security.secrets import Secret, default_redactor, register_secret
46
+
47
+ MIN_RSA_KEY_BITS = 2048
48
+ JWT_BACKDATE_SECONDS = 60 # tolerate clock drift between this host and GitHub
49
+ JWT_LIFETIME_SECONDS = 540 # GitHub allows at most 10 minutes
50
+ JWT_REUSE_MARGIN_SECONDS = 60
51
+ TOKEN_EXPIRY_MARGIN = timedelta(minutes=5)
52
+
53
+
54
+ def _b64url(data: bytes) -> str:
55
+ return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii")
56
+
57
+
58
+ class AppCredentials:
59
+ """The App ID and parsed private key. Creates short-lived JWTs."""
60
+
61
+ def __init__(
62
+ self, app_id: int, private_key_pem: Secret, *, clock: Callable[[], float] = time.time
63
+ ) -> None:
64
+ if not isinstance(app_id, int) or app_id <= 0:
65
+ raise AuthenticationError("GitHub App ID must be a positive integer")
66
+ try:
67
+ key = serialization.load_pem_private_key(
68
+ private_key_pem.reveal().encode("utf-8"), password=None
69
+ )
70
+ except Exception: # noqa: BLE001 - never chain: parser errors could echo input
71
+ raise AuthenticationError(
72
+ "GitHub App private key is malformed or encrypted "
73
+ "(expected an unencrypted PEM RSA private key)"
74
+ ) from None
75
+ if not isinstance(key, rsa.RSAPrivateKey):
76
+ raise AuthenticationError("GitHub App private key must be an RSA key")
77
+ if key.key_size < MIN_RSA_KEY_BITS:
78
+ raise AuthenticationError(
79
+ f"GitHub App private key must be at least {MIN_RSA_KEY_BITS} bits"
80
+ )
81
+ self.app_id = app_id
82
+ self._key = key
83
+ self._clock = clock
84
+ self._lock = threading.Lock()
85
+ self._jwt: Secret | None = None
86
+ self._jwt_expires = 0.0
87
+
88
+ def __repr__(self) -> str:
89
+ return f"AppCredentials(app_id={self.app_id})"
90
+
91
+ def __reduce__(self) -> NoReturn:
92
+ raise TypeError("AppCredentials cannot be pickled")
93
+
94
+ def create_jwt(self) -> Secret:
95
+ """A JWT for App-level API calls, reused until shortly before it expires."""
96
+ now = self._clock()
97
+ with self._lock:
98
+ if self._jwt is not None and now < self._jwt_expires - JWT_REUSE_MARGIN_SECONDS:
99
+ return self._jwt
100
+ issued = int(now) - JWT_BACKDATE_SECONDS
101
+ expires = int(now) + JWT_LIFETIME_SECONDS
102
+ header = _b64url(
103
+ json.dumps({"alg": "RS256", "typ": "JWT"}, separators=(",", ":")).encode()
104
+ )
105
+ claims = {"iat": issued, "exp": expires, "iss": str(self.app_id)}
106
+ payload = _b64url(json.dumps(claims, separators=(",", ":")).encode())
107
+ signing_input = f"{header}.{payload}".encode("ascii")
108
+ signature = self._key.sign(signing_input, padding.PKCS1v15(), hashes.SHA256())
109
+ token = Secret(f"{header}.{payload}.{_b64url(signature)}")
110
+ register_secret(token)
111
+ if self._jwt is not None:
112
+ default_redactor().forget(self._jwt)
113
+ self._jwt, self._jwt_expires = token, float(expires)
114
+ return token
115
+
116
+
117
+ @dataclass(frozen=True, slots=True)
118
+ class InstallationToken:
119
+ installation_id: int
120
+ repository_id: int | None
121
+ token: Secret
122
+ expires_at: datetime
123
+ permissions: Mapping[str, str]
124
+
125
+ def usable_at(self, now: datetime) -> bool:
126
+ return now < self.expires_at - TOKEN_EXPIRY_MARGIN
127
+
128
+
129
+ class InstallationTokenProvider:
130
+ """Mints and caches down-scoped installation tokens (memory only)."""
131
+
132
+ def __init__(
133
+ self,
134
+ credentials: AppCredentials,
135
+ client: GitHubClient,
136
+ *,
137
+ permissions: Mapping[str, str] = REQUIRED_PERMISSIONS,
138
+ now: Callable[[], datetime] = lambda: datetime.now(UTC),
139
+ ) -> None:
140
+ self._credentials = credentials
141
+ self._client = client
142
+ self._permissions = dict(permissions)
143
+ self._now = now
144
+ self._lock = threading.Lock()
145
+ self._cache: dict[tuple[int, int | None], InstallationToken] = {}
146
+
147
+ def app_jwt(self) -> Secret:
148
+ """A short-lived App JWT for App-level API calls (e.g. installation details)."""
149
+ return self._credentials.create_jwt()
150
+
151
+ def token(self, installation_id: int, repository_id: int | None) -> InstallationToken:
152
+ """A token for one repository (or, with ``None``, the whole installation)."""
153
+ key = (int(installation_id), None if repository_id is None else int(repository_id))
154
+ with self._lock:
155
+ cached = self._cache.get(key)
156
+ if cached is not None and cached.usable_at(self._now()):
157
+ return cached
158
+ grant = self._mint(*key)
159
+ token = InstallationToken(
160
+ installation_id=key[0],
161
+ repository_id=key[1],
162
+ token=grant.token,
163
+ expires_at=grant.expires_at,
164
+ permissions=dict(grant.permissions),
165
+ )
166
+ with self._lock:
167
+ previous = self._cache.get(key)
168
+ self._cache[key] = token
169
+ if previous is not None:
170
+ default_redactor().forget(previous.token)
171
+ return token
172
+
173
+ def _mint(self, installation_id: int, repository_id: int | None) -> InstallationTokenGrant:
174
+ jwt = self._credentials.create_jwt()
175
+ try:
176
+ grant = self._client.create_installation_token(
177
+ jwt,
178
+ installation_id,
179
+ repository_ids=None if repository_id is None else [repository_id],
180
+ permissions=self._permissions,
181
+ )
182
+ except GitHubUnauthorizedError:
183
+ raise AuthenticationError(
184
+ "GitHub rejected the App credentials (check the App ID and private key)"
185
+ ) from None
186
+ except GitHubNotFoundError:
187
+ raise AuthorizationError(
188
+ "GitHub App installation not found (the App may have been uninstalled)"
189
+ ) from None
190
+ except GitHubValidationError:
191
+ raise AuthorizationError(
192
+ "the installation cannot access this repository with the required permissions"
193
+ ) from None
194
+ except GitHubForbiddenError:
195
+ raise AuthorizationError(
196
+ "GitHub refused an installation token (installation suspended?)"
197
+ ) from None
198
+ if grant.expires_at.tzinfo is None or grant.expires_at <= self._now():
199
+ raise AuthenticationError("GitHub returned an already expired installation token")
200
+ if repository_id is not None and grant.repository_ids not in ((), (repository_id,)):
201
+ raise AuthorizationError("installation token is not scoped to the requested repository")
202
+ missing = missing_permissions(grant.permissions, self._permissions)
203
+ if missing:
204
+ raise InsufficientPermissionsError(missing)
205
+ return grant
206
+
207
+ def invalidate(self, installation_id: int, repository_id: int | None = None) -> None:
208
+ """Forget cached tokens for an installation (or one of its repositories)."""
209
+ with self._lock:
210
+ keys = [
211
+ k
212
+ for k in self._cache
213
+ if k[0] == installation_id and (repository_id is None or k[1] == repository_id)
214
+ ]
215
+ dropped = [self._cache.pop(k) for k in keys]
216
+ for token in dropped:
217
+ default_redactor().forget(token.token)
@@ -0,0 +1,172 @@
1
+ """Check Run content for the GitHub App, rendered from a :class:`ScanResult`.
2
+
3
+ The conclusion comes from :class:`~commitguard.services.enforcement.EnforcementDecision`
4
+ (BLOCK -> ``failure``; WARN -> ``success`` with warnings listed; could not
5
+ verify -> ``failure``/``timed_out``). Security violations are never reported
6
+ as successful checks. All untrusted values (identities, subjects, policy
7
+ descriptions) are Markdown-escaped and truncated; output is capped at
8
+ :data:`~commitguard.github.checks.MAX_CHECK_FINDINGS` findings with counts for
9
+ the rest, and never includes secrets or infrastructure details.
10
+ """
11
+
12
+ from commitguard.core.decision import Action
13
+ from commitguard.github.checks import MAX_CHECK_FINDINGS, CheckRunConclusion, CheckRunOutput
14
+ from commitguard.github.markdown import escape_markdown as md
15
+ from commitguard.services.enforcement import EnforcementState, FailureKind
16
+ from commitguard.services.reports import CommitReport, EvaluatedFinding
17
+ from commitguard.services.scan import ScanResult
18
+
19
+ _REMEDIATION = (
20
+ "Remove the prohibited attribution from the listed commits (for example by "
21
+ "rewording them locally) and push the corrected commits. CommitGuard never "
22
+ "rewrites history or modifies the repository."
23
+ )
24
+
25
+
26
+ def queued_output(description: str) -> CheckRunOutput:
27
+ return CheckRunOutput(
28
+ title="Queued",
29
+ summary=f"CommitGuard will scan {md(description, 120)}.",
30
+ )
31
+
32
+
33
+ def in_progress_output(commits: int, policy_source: str) -> CheckRunOutput:
34
+ return CheckRunOutput(
35
+ title=f"Scanning {commits} commit(s)",
36
+ summary=(
37
+ f"CommitGuard is scanning **{commits}** commit(s).\n\nPolicy: {md(policy_source, 200)}"
38
+ ),
39
+ )
40
+
41
+
42
+ def error_output(kind: FailureKind, reason: str) -> tuple[CheckRunConclusion, CheckRunOutput]:
43
+ conclusion = (
44
+ CheckRunConclusion.TIMED_OUT if kind is FailureKind.TIMEOUT else CheckRunConclusion.FAILURE
45
+ )
46
+ title = {
47
+ FailureKind.CONFIGURATION: "Configuration error",
48
+ FailureKind.AUTHORIZATION: "Not authorized",
49
+ FailureKind.INFRASTRUCTURE: "Scan could not be completed",
50
+ FailureKind.TIMEOUT: "Scan timed out",
51
+ FailureKind.INTERNAL: "Scan could not be completed",
52
+ }[kind]
53
+ summary = (
54
+ "## CommitGuard could not verify repository policy\n\n"
55
+ f"Reason: {md(reason, 500)}\n\n"
56
+ "Security validation could not be completed, so this check fails (fail closed). "
57
+ "Push a new commit or reopen the pull request to scan again."
58
+ )
59
+ return conclusion, CheckRunOutput(title=title, summary=summary)
60
+
61
+
62
+ def _finding_lines(index: int, commit: CommitReport, item: EvaluatedFinding) -> list[str]:
63
+ finding = item.finding
64
+ return [
65
+ f"#### {index}. {md(finding.title, 120)}",
66
+ "",
67
+ f"- **Rule:** {finding.rule_id}",
68
+ f"- **Commit:** {md(commit.short_sha, 40)} {md(commit.subject, 120)}",
69
+ f"- **Identity:** {md(finding.evidence[0].value, 200)}",
70
+ f"- **Source:** {md(finding.evidence[0].source.label, 60)}",
71
+ f"- **Severity:** {finding.severity.value}",
72
+ f"- **Action:** {item.action.value}",
73
+ f"- **Remediation:** {md(finding.remediation, 300)}",
74
+ "",
75
+ ]
76
+
77
+
78
+ def completed_output(result: ScanResult) -> tuple[CheckRunConclusion, CheckRunOutput]:
79
+ report, stats, meta = result.report, result.statistics, result.metadata
80
+ decision = result.enforcement
81
+ conclusion = CheckRunConclusion(decision.check_conclusion)
82
+ blocked = decision.state is EnforcementState.BLOCKED
83
+ with_warnings = decision.state is EnforcementState.PASSED_WITH_WARNINGS
84
+
85
+ if blocked:
86
+ heading, title = "CommitGuard: BLOCKED", f"Blocked: {stats.violations} violation(s)"
87
+ elif with_warnings:
88
+ heading, title = (
89
+ "CommitGuard: PASS (with warnings)",
90
+ f"Passed with {stats.warnings} warning(s)",
91
+ )
92
+ else:
93
+ heading, title = (
94
+ "CommitGuard: PASS",
95
+ f"Passed: {stats.commits_scanned} commit(s) scanned",
96
+ )
97
+
98
+ ci = report.ci
99
+ rows = [
100
+ "| | |",
101
+ "|---|---|",
102
+ f"| Commits scanned | {stats.commits_scanned} |",
103
+ f"| Violations | {stats.violations} |",
104
+ f"| Warnings | {stats.warnings} |",
105
+ f"| Detector failures | {stats.detector_failures} |",
106
+ ]
107
+ if ci is not None:
108
+ rows.append(f"| Policy | {md(ci.policy_source, 200)} |")
109
+ if ci.base_sha and ci.head_sha:
110
+ rows.append(f"| Range | {ci.base_sha[:12]}..{ci.head_sha[:12]} |")
111
+ elif ci.head_sha:
112
+ rows.append(f"| Commit | {ci.head_sha[:12]} |")
113
+ rows.append(f"| Scan ID | {meta.scan_id} |")
114
+ summary = [f"## {heading}", "", *rows, ""]
115
+ if not blocked and not with_warnings:
116
+ summary += ["All configured CommitGuard policies passed.", ""]
117
+ if ci is not None and ci.policy_weakenings:
118
+ summary += [
119
+ "### Security policy modification detected",
120
+ "",
121
+ "The evaluated commits attempt to weaken an existing CommitGuard policy. "
122
+ "They were evaluated with the trusted policy; additional authorization may be "
123
+ "required.",
124
+ "",
125
+ *[f"- {md(change, 200)}" for change in ci.policy_weakenings],
126
+ "",
127
+ ]
128
+ if ci is not None and ci.notices:
129
+ summary += ["### Notices", "", *[f"- {md(n, 400)}" for n in ci.notices], ""]
130
+
131
+ ordered = sorted(report.commits, key=lambda c: -c.action.rank)
132
+ failures = [(c, f) for c in ordered for f in c.failures]
133
+ violations = [(c, f) for c in ordered for f in c.findings if f.action is Action.BLOCK]
134
+ warnings = [(c, f) for c in ordered for f in c.findings if f.action is Action.WARN]
135
+
136
+ text: list[str] = []
137
+ if failures:
138
+ text += ["### Detector failures", ""]
139
+ for commit, failure in failures[:MAX_CHECK_FINDINGS]:
140
+ text.append(
141
+ f"- {md(commit.short_sha, 40)}: {md(failure.failure.detector, 60)} did not "
142
+ f"complete ({md(failure.failure.message, 200)}); action {failure.action.value}"
143
+ )
144
+ text.append("")
145
+ shown = 0
146
+ for label, items in (("Violations", violations), ("Warnings", warnings)):
147
+ if not items:
148
+ continue
149
+ text += [f"### {label}", ""]
150
+ budget = max(0, MAX_CHECK_FINDINGS - shown)
151
+ for index, (commit, item) in enumerate(items[:budget], start=1):
152
+ text += _finding_lines(index, commit, item)
153
+ shown += min(len(items), budget)
154
+ if len(items) > budget:
155
+ text += [f"{label}: {len(items)}. Showing first {budget}.", ""]
156
+ total = len(violations) + len(warnings)
157
+ if total > MAX_CHECK_FINDINGS:
158
+ target = (
159
+ f"{ci.base_sha}..{ci.head_sha}"
160
+ if ci is not None and ci.base_sha and ci.head_sha
161
+ else "<range>"
162
+ )
163
+ text += [
164
+ f"Showing {MAX_CHECK_FINDINGS} of {total} findings. Use the CommitGuard CLI for the "
165
+ f"complete machine-readable report: `commitguard scan {target} --format json`",
166
+ "",
167
+ ]
168
+ if blocked:
169
+ text += ["### Remediation", "", _REMEDIATION, ""]
170
+ return conclusion, CheckRunOutput(
171
+ title=title, summary="\n".join(summary), text="\n".join(text) or None
172
+ )
@@ -0,0 +1,210 @@
1
+ """Check output built from a report, for both GitHub integrations.
2
+
3
+ * ``build_check_output`` feeds the Actions job summary and workflow annotations.
4
+ * The Check Run types at the end of this module are what the GitHub App sends
5
+ to the Checks API (content rendered by :mod:`commitguard.github.check_runs`).
6
+
7
+ Nothing here talks to GitHub. Findings already carry rule ID, severity,
8
+ message, evidence and commit, so a SARIF exporter can be built from the same
9
+ report later.
10
+ """
11
+
12
+ from enum import StrEnum
13
+
14
+ from pydantic import BaseModel, ConfigDict
15
+
16
+ from commitguard.core.decision import Action
17
+ from commitguard.services.reports import ScanReport
18
+
19
+ MAX_ANNOTATIONS_PER_LEVEL = 10 # GitHub shows at most 10 errors and 10 warnings per step
20
+
21
+
22
+ class AnnotationLevel(StrEnum):
23
+ FAILURE = "failure"
24
+ WARNING = "warning"
25
+ NOTICE = "notice"
26
+
27
+
28
+ class Annotation(BaseModel):
29
+ model_config = ConfigDict(frozen=True, extra="forbid")
30
+
31
+ level: AnnotationLevel
32
+ title: str
33
+ message: str
34
+ rule_id: str | None = None
35
+ commit_sha: str | None = None
36
+
37
+
38
+ class CheckOutput(BaseModel):
39
+ model_config = ConfigDict(frozen=True, extra="forbid")
40
+
41
+ conclusion: str # "success" | "failure"
42
+ title: str
43
+ counts: dict[str, int]
44
+ annotations: tuple[Annotation, ...]
45
+ omitted_annotations: int = 0
46
+
47
+
48
+ def check_conclusion(action: Action, fail_on: Action = Action.BLOCK) -> str:
49
+ return "failure" if action.rank >= fail_on.rank else "success"
50
+
51
+
52
+ def build_check_output(report: ScanReport, *, fail_on: Action = Action.BLOCK) -> CheckOutput:
53
+ commits = report.commits
54
+ counts = {
55
+ "commits": len(commits),
56
+ "block": sum(1 for c in commits if c.action is Action.BLOCK),
57
+ "warn": sum(1 for c in commits if c.action is Action.WARN),
58
+ "allow": sum(1 for c in commits if c.action is Action.ALLOW),
59
+ "findings": sum(len(c.findings) for c in commits),
60
+ "failures": sum(len(c.failures) for c in commits),
61
+ }
62
+ annotations: list[Annotation] = []
63
+ per_level: dict[AnnotationLevel, int] = {}
64
+ omitted = 0
65
+
66
+ def add(annotation: Annotation) -> None:
67
+ nonlocal omitted
68
+ used = per_level.get(annotation.level, 0)
69
+ if used >= MAX_ANNOTATIONS_PER_LEVEL:
70
+ omitted += 1
71
+ return
72
+ per_level[annotation.level] = used + 1
73
+ annotations.append(annotation)
74
+
75
+ for commit in sorted(commits, key=lambda c: -c.action.rank):
76
+ for failure in commit.failures:
77
+ add(
78
+ Annotation(
79
+ level=AnnotationLevel.FAILURE,
80
+ title="CommitGuard: detector failure",
81
+ message=f"{commit.short_sha}: {failure.failure.detector} did not complete "
82
+ f"({failure.failure.message}); analysis incomplete",
83
+ commit_sha=commit.commit_sha,
84
+ )
85
+ )
86
+ for item in commit.findings:
87
+ if item.action is Action.ALLOW:
88
+ continue
89
+ finding = item.finding
90
+ level = (
91
+ AnnotationLevel.FAILURE
92
+ if item.action.rank >= fail_on.rank
93
+ else AnnotationLevel.WARNING
94
+ )
95
+ add(
96
+ Annotation(
97
+ level=level,
98
+ title=f"CommitGuard: {finding.title}",
99
+ message=(
100
+ f"{commit.short_sha} {commit.subject}: {finding.evidence[0].value} "
101
+ f"[{finding.rule_id}, {finding.severity.value}, action {item.action.value}]"
102
+ ),
103
+ rule_id=finding.rule_id,
104
+ commit_sha=commit.commit_sha,
105
+ )
106
+ )
107
+ if report.ci is not None:
108
+ for notice in report.ci.notices:
109
+ add(Annotation(level=AnnotationLevel.NOTICE, title="CommitGuard", message=notice))
110
+
111
+ conclusion = check_conclusion(report.action, fail_on)
112
+ if conclusion == "failure":
113
+ title = f"CommitGuard: {counts['block']} of {counts['commits']} commit(s) blocked"
114
+ if counts["block"] == 0:
115
+ title = f"CommitGuard: failed on warnings ({counts['warn']} commit(s))"
116
+ elif counts["warn"]:
117
+ title = f"CommitGuard: passed with warnings ({counts['warn']} commit(s))"
118
+ else:
119
+ title = f"CommitGuard: passed ({counts['commits']} commit(s) checked)"
120
+ return CheckOutput(
121
+ conclusion=conclusion,
122
+ title=title,
123
+ counts=counts,
124
+ annotations=tuple(annotations),
125
+ omitted_annotations=omitted,
126
+ )
127
+
128
+
129
+ # --------------------------------------------------------------------------- #
130
+ # GitHub App Check Runs (Checks API)
131
+ # --------------------------------------------------------------------------- #
132
+ # The App publishes its own check, separate from the Action's "commitguard" job:
133
+ #
134
+ # * "commitguard-app" - pull requests: the check to require in branch protection;
135
+ # * "commitguard-app/push" - pushes: informational (the commits are already on GitHub).
136
+ #
137
+ # Separate names keep a push scan (only the newly pushed commits) from ever
138
+ # replacing a pull request scan (all commits of the PR) on the same SHA.
139
+ #
140
+ # Commit metadata has no file or line, so no annotations are sent: findings are
141
+ # listed in the summary and text instead of being attached to invented locations.
142
+
143
+ APP_CHECK_NAME = "commitguard-app"
144
+ APP_PUSH_CHECK_NAME = "commitguard-app/push"
145
+ MAX_CHECK_FINDINGS = 20
146
+ MAX_CHECK_TITLE_CHARS = 200
147
+ MAX_CHECK_TEXT_CHARS = 60_000 # GitHub limit: 65535 characters for summary and text
148
+
149
+
150
+ class CheckRunStatus(StrEnum):
151
+ QUEUED = "queued"
152
+ IN_PROGRESS = "in_progress"
153
+ COMPLETED = "completed"
154
+
155
+
156
+ class CheckRunConclusion(StrEnum):
157
+ SUCCESS = "success"
158
+ FAILURE = "failure"
159
+ NEUTRAL = "neutral"
160
+ CANCELLED = "cancelled"
161
+ TIMED_OUT = "timed_out"
162
+ ACTION_REQUIRED = "action_required"
163
+
164
+
165
+ class CheckRunOutput(BaseModel):
166
+ model_config = ConfigDict(frozen=True, extra="forbid")
167
+
168
+ title: str
169
+ summary: str
170
+ text: str | None = None
171
+
172
+ def to_api(self) -> dict[str, str]:
173
+ output = {
174
+ "title": self.title[:MAX_CHECK_TITLE_CHARS],
175
+ "summary": _clip(self.summary),
176
+ }
177
+ if self.text:
178
+ output["text"] = _clip(self.text)
179
+ return output
180
+
181
+
182
+ def _clip(text: str) -> str:
183
+ if len(text) <= MAX_CHECK_TEXT_CHARS:
184
+ return text
185
+ return text[: MAX_CHECK_TEXT_CHARS - 40] + "\n\n_Output truncated by CommitGuard._\n"
186
+
187
+
188
+ def check_run_create_payload(
189
+ *, name: str, head_sha: str, external_id: str, output: CheckRunOutput
190
+ ) -> dict[str, object]:
191
+ return {
192
+ "name": name,
193
+ "head_sha": head_sha,
194
+ "status": CheckRunStatus.QUEUED.value,
195
+ "external_id": external_id,
196
+ "output": output.to_api(),
197
+ }
198
+
199
+
200
+ def check_run_update_payload(
201
+ status: CheckRunStatus,
202
+ output: CheckRunOutput,
203
+ conclusion: CheckRunConclusion | None = None,
204
+ ) -> dict[str, object]:
205
+ if (status is CheckRunStatus.COMPLETED) != (conclusion is not None):
206
+ raise ValueError("a conclusion is required exactly when the check run is completed")
207
+ payload: dict[str, object] = {"status": status.value, "output": output.to_api()}
208
+ if conclusion is not None:
209
+ payload["conclusion"] = conclusion.value
210
+ return payload