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.
- commitguard/__init__.py +26 -0
- commitguard/__main__.py +6 -0
- commitguard/api/__init__.py +18 -0
- commitguard/api/app.py +1376 -0
- commitguard/api/governance.py +1085 -0
- commitguard/api/hosting.py +196 -0
- commitguard/api/http.py +252 -0
- commitguard/api/settings.py +169 -0
- commitguard/audit/__init__.py +13 -0
- commitguard/audit/logger.py +34 -0
- commitguard/audit/models.py +222 -0
- commitguard/audit/storage.py +59 -0
- commitguard/ci/__init__.py +7 -0
- commitguard/ci/context.py +60 -0
- commitguard/cli/__init__.py +6 -0
- commitguard/cli/app.py +74 -0
- commitguard/cli/commands/__init__.py +1 -0
- commitguard/cli/commands/benchmark.py +441 -0
- commitguard/cli/commands/check.py +100 -0
- commitguard/cli/commands/ci.py +165 -0
- commitguard/cli/commands/dashboard.py +141 -0
- commitguard/cli/commands/doctor.py +533 -0
- commitguard/cli/commands/github.py +449 -0
- commitguard/cli/commands/hook.py +156 -0
- commitguard/cli/commands/init.py +137 -0
- commitguard/cli/commands/install.py +152 -0
- commitguard/cli/commands/policy.py +36 -0
- commitguard/cli/commands/report.py +39 -0
- commitguard/cli/commands/reproduce.py +123 -0
- commitguard/cli/commands/scan.py +47 -0
- commitguard/cli/common.py +44 -0
- commitguard/cli/output.py +89 -0
- commitguard/cli/render.py +367 -0
- commitguard/config/__init__.py +6 -0
- commitguard/config/defaults.py +53 -0
- commitguard/config/enforcement.py +53 -0
- commitguard/config/loader.py +174 -0
- commitguard/config/schema.py +105 -0
- commitguard/config/sources.py +183 -0
- commitguard/controlplane/__init__.py +24 -0
- commitguard/controlplane/access.py +231 -0
- commitguard/controlplane/commands.py +393 -0
- commitguard/controlplane/errors.py +88 -0
- commitguard/controlplane/identity.py +478 -0
- commitguard/controlplane/members.py +219 -0
- commitguard/controlplane/notifications.py +787 -0
- commitguard/controlplane/pagination.py +146 -0
- commitguard/controlplane/policies.py +1204 -0
- commitguard/controlplane/queries.py +1814 -0
- commitguard/controlplane/results.py +909 -0
- commitguard/controlplane/rules.py +184 -0
- commitguard/controlplane/views.py +799 -0
- commitguard/core/__init__.py +6 -0
- commitguard/core/context.py +31 -0
- commitguard/core/decision.py +58 -0
- commitguard/core/engine.py +82 -0
- commitguard/core/result.py +177 -0
- commitguard/detectors/__init__.py +6 -0
- commitguard/detectors/base.py +58 -0
- commitguard/detectors/bot.py +87 -0
- commitguard/detectors/coauthor.py +86 -0
- commitguard/detectors/identity.py +76 -0
- commitguard/detectors/registry.py +72 -0
- commitguard/detectors/trailer.py +211 -0
- commitguard/exceptions/__init__.py +33 -0
- commitguard/exceptions/base.py +9 -0
- commitguard/exceptions/configuration.py +22 -0
- commitguard/exceptions/detection.py +11 -0
- commitguard/exceptions/git.py +41 -0
- commitguard/exceptions/service.py +25 -0
- commitguard/git/__init__.py +12 -0
- commitguard/git/commands.py +101 -0
- commitguard/git/commit.py +97 -0
- commitguard/git/diff.py +36 -0
- commitguard/git/hooks.py +527 -0
- commitguard/git/push.py +93 -0
- commitguard/git/ranges.py +71 -0
- commitguard/git/repository.py +447 -0
- commitguard/github/__init__.py +34 -0
- commitguard/github/actions.py +163 -0
- commitguard/github/app.py +935 -0
- commitguard/github/auth.py +217 -0
- commitguard/github/check_runs.py +172 -0
- commitguard/github/checks.py +210 -0
- commitguard/github/client.py +844 -0
- commitguard/github/enforcement_status.py +209 -0
- commitguard/github/errors.py +129 -0
- commitguard/github/events.py +563 -0
- commitguard/github/identifiers.py +90 -0
- commitguard/github/installations.py +566 -0
- commitguard/github/markdown.py +19 -0
- commitguard/github/permissions.py +70 -0
- commitguard/github/pull_requests.py +53 -0
- commitguard/github/queue.py +47 -0
- commitguard/github/recovery.py +124 -0
- commitguard/github/repositories.py +305 -0
- commitguard/github/server.py +52 -0
- commitguard/github/settings.py +174 -0
- commitguard/github/storage.py +2315 -0
- commitguard/github/webhooks.py +129 -0
- commitguard/github/worker.py +628 -0
- commitguard/github/workflow.py +286 -0
- commitguard/governance/__init__.py +26 -0
- commitguard/governance/bulk.py +765 -0
- commitguard/governance/cache.py +88 -0
- commitguard/governance/common.py +216 -0
- commitguard/governance/exceptions.py +861 -0
- commitguard/governance/groups.py +448 -0
- commitguard/governance/inventory.py +386 -0
- commitguard/governance/posture.py +1272 -0
- commitguard/governance/resolver.py +632 -0
- commitguard/governance/rollouts.py +760 -0
- commitguard/governance/rules.py +371 -0
- commitguard/governance/schedules.py +663 -0
- commitguard/governance/service.py +120 -0
- commitguard/governance/settings.py +365 -0
- commitguard/governance/simulation.py +618 -0
- commitguard/governance/workflow.py +734 -0
- commitguard/notifications/__init__.py +2 -0
- commitguard/notifications/channels/__init__.py +1 -0
- commitguard/notifications/channels/base.py +22 -0
- commitguard/notifications/channels/email.py +110 -0
- commitguard/notifications/channels/in_app.py +74 -0
- commitguard/notifications/channels/sink.py +58 -0
- commitguard/notifications/channels/webhook.py +233 -0
- commitguard/notifications/deduplication.py +57 -0
- commitguard/notifications/dispatcher.py +201 -0
- commitguard/notifications/models.py +439 -0
- commitguard/notifications/outbox.py +106 -0
- commitguard/notifications/preferences.py +224 -0
- commitguard/notifications/retry.py +282 -0
- commitguard/notifications/service.py +128 -0
- commitguard/notifications/settings.py +167 -0
- commitguard/notifications/templates.py +108 -0
- commitguard/observability/__init__.py +5 -0
- commitguard/observability/logging.py +161 -0
- commitguard/observability/metrics.py +105 -0
- commitguard/policies/__init__.py +6 -0
- commitguard/policies/defaults.py +48 -0
- commitguard/policies/evaluator.py +66 -0
- commitguard/policies/governance.py +498 -0
- commitguard/policies/loader.py +23 -0
- commitguard/policies/mandatory.py +52 -0
- commitguard/policies/model.py +46 -0
- commitguard/provenance/__init__.py +9 -0
- commitguard/provenance/author.py +146 -0
- commitguard/provenance/committer.py +16 -0
- commitguard/provenance/normalization.py +158 -0
- commitguard/provenance/signatures.py +34 -0
- commitguard/provenance/trailers.py +256 -0
- commitguard/research/__init__.py +26 -0
- commitguard/research/compare.py +231 -0
- commitguard/research/datasets.py +1484 -0
- commitguard/research/detection.py +183 -0
- commitguard/research/environment.py +185 -0
- commitguard/research/gitenv.py +108 -0
- commitguard/research/hooks.py +247 -0
- commitguard/research/metrics.py +85 -0
- commitguard/research/performance.py +194 -0
- commitguard/research/platform.py +288 -0
- commitguard/research/report.py +372 -0
- commitguard/research/repository.py +111 -0
- commitguard/research/reproduction.py +297 -0
- commitguard/research/results.py +94 -0
- commitguard/rules/__init__.py +11 -0
- commitguard/rules/data/ai-domains.yaml +51 -0
- commitguard/rules/data/ai-identities.yaml +131 -0
- commitguard/rules/data/bot-identities.yaml +53 -0
- commitguard/rules/data/patterns.yaml +52 -0
- commitguard/rules/loader.py +102 -0
- commitguard/rules/matcher.py +212 -0
- commitguard/rules/models.py +269 -0
- commitguard/security/__init__.py +5 -0
- commitguard/security/hashing.py +30 -0
- commitguard/security/rate_limit.py +33 -0
- commitguard/security/safe_yaml.py +69 -0
- commitguard/security/sanitization.py +85 -0
- commitguard/security/secrets.py +169 -0
- commitguard/security/validation.py +89 -0
- commitguard/services/__init__.py +15 -0
- commitguard/services/analysis.py +119 -0
- commitguard/services/audit.py +95 -0
- commitguard/services/ci.py +383 -0
- commitguard/services/enforcement.py +102 -0
- commitguard/services/hooks.py +254 -0
- commitguard/services/remediation.py +99 -0
- commitguard/services/reports.py +146 -0
- commitguard/services/scan.py +172 -0
- commitguard/utils/__init__.py +1 -0
- commitguard/utils/filesystem.py +72 -0
- commitguard/utils/platform.py +35 -0
- commitguard/utils/subprocess.py +84 -0
- commitguardian-0.1.0.dist-info/METADATA +694 -0
- commitguardian-0.1.0.dist-info/RECORD +197 -0
- commitguardian-0.1.0.dist-info/WHEEL +4 -0
- commitguardian-0.1.0.dist-info/entry_points.txt +2 -0
- 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
|