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,566 @@
|
|
|
1
|
+
"""Installation lifecycle and repository authorization.
|
|
2
|
+
|
|
3
|
+
Two sources of truth, used for different decisions:
|
|
4
|
+
|
|
5
|
+
* **Stored installation state** (from ``installation`` /
|
|
6
|
+
``installation_repositories`` webhooks) gives fast *negative* answers: a
|
|
7
|
+
deleted or suspended installation is rejected before anything else happens,
|
|
8
|
+
and its mirrors and cached tokens are discarded.
|
|
9
|
+
* **GitHub itself** gives the authoritative *positive* answer: before any
|
|
10
|
+
repository access the worker mints an installation token down-scoped to that
|
|
11
|
+
single repository (GitHub refuses if the installation does not cover it) and
|
|
12
|
+
looks the repository up by its immutable ID with that token. A missing or
|
|
13
|
+
missed webhook therefore cannot grant access, and a payload naming an
|
|
14
|
+
unrelated installation, owner or repository gets nothing.
|
|
15
|
+
|
|
16
|
+
Connection state transitions are detected against the stored state, inside the
|
|
17
|
+
same transaction that applies them:
|
|
18
|
+
|
|
19
|
+
====================================== =============================================
|
|
20
|
+
Transition Notification
|
|
21
|
+
====================================== =============================================
|
|
22
|
+
active -> suspended / deleted ``installation_disconnected`` (enforcement
|
|
23
|
+
at risk)
|
|
24
|
+
suspended -> active (unsuspend) ``installation_reconnected``
|
|
25
|
+
new installation for an account whose ``installation_reconnected``
|
|
26
|
+
previous installation was removed
|
|
27
|
+
suspended -> deleted, deleted -> none: already disconnected
|
|
28
|
+
deleted, repeated events
|
|
29
|
+
====================================== =============================================
|
|
30
|
+
|
|
31
|
+
Webhooks can arrive out of order. An installation stored as ``deleted`` is
|
|
32
|
+
not revived by a late ``suspend``, ``unsuspend`` or ``new_permissions_accepted``
|
|
33
|
+
event; only ``created`` makes it active again.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import sqlite3
|
|
37
|
+
from collections.abc import Callable, Sequence
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
from commitguard.audit.models import GITHUB_ACTOR, Actor, AuditEventType
|
|
43
|
+
from commitguard.core.result import Severity
|
|
44
|
+
from commitguard.github.auth import InstallationToken, InstallationTokenProvider
|
|
45
|
+
from commitguard.github.client import GitHubClient, InstallationInfo
|
|
46
|
+
from commitguard.github.errors import (
|
|
47
|
+
AuthenticationError,
|
|
48
|
+
AuthorizationError,
|
|
49
|
+
GitHubAPIError,
|
|
50
|
+
GitHubForbiddenError,
|
|
51
|
+
GitHubNotFoundError,
|
|
52
|
+
GitHubUnauthorizedError,
|
|
53
|
+
)
|
|
54
|
+
from commitguard.github.events import (
|
|
55
|
+
InstallationAction,
|
|
56
|
+
InstallationEvent,
|
|
57
|
+
InstallationRepositoriesEvent,
|
|
58
|
+
RepositoriesAction,
|
|
59
|
+
)
|
|
60
|
+
from commitguard.github.identifiers import AccountType, RepositoryRef
|
|
61
|
+
from commitguard.github.repositories import MirrorManager
|
|
62
|
+
from commitguard.github.storage import InstallationRecord, InstallationState, SqliteStateStore
|
|
63
|
+
from commitguard.notifications.deduplication import domain_key
|
|
64
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
65
|
+
from commitguard.notifications.outbox import emit
|
|
66
|
+
from commitguard.observability.logging import get_logger
|
|
67
|
+
from commitguard.services.audit import AuditService
|
|
68
|
+
|
|
69
|
+
log = get_logger(__name__)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True, slots=True)
|
|
73
|
+
class RepositorySync:
|
|
74
|
+
installation_id: int
|
|
75
|
+
repositories: tuple[RepositoryRef, ...]
|
|
76
|
+
added: tuple[RepositoryRef, ...]
|
|
77
|
+
removed: tuple[RepositoryRef, ...]
|
|
78
|
+
synced_at: datetime
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass(frozen=True, slots=True)
|
|
82
|
+
class AuthorizedRepository:
|
|
83
|
+
installation_id: int
|
|
84
|
+
repository: RepositoryRef # canonical owner/name from the API
|
|
85
|
+
token: InstallationToken
|
|
86
|
+
default_branch: str | None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class InstallationService:
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
store: SqliteStateStore,
|
|
93
|
+
tokens: InstallationTokenProvider,
|
|
94
|
+
client: GitHubClient,
|
|
95
|
+
mirrors: MirrorManager,
|
|
96
|
+
audit: AuditService,
|
|
97
|
+
*,
|
|
98
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
99
|
+
) -> None:
|
|
100
|
+
self._store = store
|
|
101
|
+
self._tokens = tokens
|
|
102
|
+
self._client = client
|
|
103
|
+
self._mirrors = mirrors
|
|
104
|
+
self._audit = audit
|
|
105
|
+
self._now = now
|
|
106
|
+
self._discovery_listeners: list[Callable[[int, int, Sequence[RepositoryRef]], None]] = []
|
|
107
|
+
|
|
108
|
+
def add_discovery_listener(
|
|
109
|
+
self, listener: Callable[[int, int, Sequence[RepositoryRef]], None]
|
|
110
|
+
) -> None:
|
|
111
|
+
"""Call ``listener(account_id, installation_id, repositories)`` for listed repositories.
|
|
112
|
+
|
|
113
|
+
Organization governance uses it to record newly discovered repositories
|
|
114
|
+
and apply the organization's onboarding defaults. A listener failure is
|
|
115
|
+
logged and never fails the GitHub event.
|
|
116
|
+
"""
|
|
117
|
+
self._discovery_listeners.append(listener)
|
|
118
|
+
|
|
119
|
+
def _discovered(
|
|
120
|
+
self, account_id: int, installation_id: int, repositories: Sequence[RepositoryRef]
|
|
121
|
+
) -> None:
|
|
122
|
+
if not repositories:
|
|
123
|
+
return
|
|
124
|
+
for listener in self._discovery_listeners:
|
|
125
|
+
try:
|
|
126
|
+
listener(account_id, installation_id, repositories)
|
|
127
|
+
except Exception as exc: # noqa: BLE001 - discovery must not fail the event
|
|
128
|
+
log.error("repository_discovery_listener_failed", error_type=type(exc).__name__)
|
|
129
|
+
|
|
130
|
+
# -- webhooks -------------------------------------------------------- #
|
|
131
|
+
def handle_installation(self, event: InstallationEvent) -> None:
|
|
132
|
+
now = self._now()
|
|
133
|
+
existing = self._store.get_installation(event.installation_id)
|
|
134
|
+
record = InstallationRecord(
|
|
135
|
+
installation_id=event.installation_id,
|
|
136
|
+
account_id=event.account.id,
|
|
137
|
+
account_login=event.account.login,
|
|
138
|
+
account_type=event.account.type,
|
|
139
|
+
repository_selection=event.repository_selection,
|
|
140
|
+
state=InstallationState.ACTIVE,
|
|
141
|
+
permissions=event.permissions,
|
|
142
|
+
created_at=existing.created_at if existing else now,
|
|
143
|
+
updated_at=now,
|
|
144
|
+
)
|
|
145
|
+
installation_id = event.installation_id
|
|
146
|
+
deleted = existing is not None and existing.state is InstallationState.DELETED
|
|
147
|
+
if deleted and event.action is not InstallationAction.CREATED:
|
|
148
|
+
log.info("installation_event_after_removal_ignored", action=event.action.value)
|
|
149
|
+
return
|
|
150
|
+
before = existing.state if existing else None
|
|
151
|
+
if event.action is InstallationAction.CREATED:
|
|
152
|
+
self._store.replace_repositories(event.installation_id, event.repositories, now)
|
|
153
|
+
self._apply(
|
|
154
|
+
record,
|
|
155
|
+
before,
|
|
156
|
+
AuditEventType.INSTALLATION_CREATED,
|
|
157
|
+
actor=GITHUB_ACTOR,
|
|
158
|
+
installation_id=installation_id,
|
|
159
|
+
account_id=event.account.id,
|
|
160
|
+
account_type=event.account.type.value,
|
|
161
|
+
repository_selection=event.repository_selection,
|
|
162
|
+
repositories=len(event.repositories),
|
|
163
|
+
)
|
|
164
|
+
self._discovered(event.account.id, installation_id, event.repositories)
|
|
165
|
+
elif event.action is InstallationAction.DELETED:
|
|
166
|
+
repositories = len(self._store.list_repositories(installation_id))
|
|
167
|
+
self._apply(
|
|
168
|
+
record.model_copy(update={"state": InstallationState.DELETED}),
|
|
169
|
+
before,
|
|
170
|
+
AuditEventType.INSTALLATION_REMOVED,
|
|
171
|
+
affected=repositories,
|
|
172
|
+
actor=GITHUB_ACTOR,
|
|
173
|
+
installation_id=installation_id,
|
|
174
|
+
)
|
|
175
|
+
self._tokens.invalidate(event.installation_id)
|
|
176
|
+
self._mirrors.remove_installation(event.installation_id)
|
|
177
|
+
elif event.action is InstallationAction.SUSPEND:
|
|
178
|
+
self._tokens.invalidate(event.installation_id)
|
|
179
|
+
self._apply(
|
|
180
|
+
record.model_copy(update={"state": InstallationState.SUSPENDED}),
|
|
181
|
+
before,
|
|
182
|
+
AuditEventType.INSTALLATION_SUSPENDED,
|
|
183
|
+
actor=GITHUB_ACTOR,
|
|
184
|
+
installation_id=installation_id,
|
|
185
|
+
)
|
|
186
|
+
elif event.action is InstallationAction.UNSUSPEND:
|
|
187
|
+
self._apply(
|
|
188
|
+
record,
|
|
189
|
+
before,
|
|
190
|
+
AuditEventType.INSTALLATION_UNSUSPENDED,
|
|
191
|
+
actor=GITHUB_ACTOR,
|
|
192
|
+
installation_id=installation_id,
|
|
193
|
+
)
|
|
194
|
+
else: # new_permissions_accepted
|
|
195
|
+
state = existing.state if existing else InstallationState.ACTIVE
|
|
196
|
+
self._tokens.invalidate(event.installation_id)
|
|
197
|
+
self._apply(
|
|
198
|
+
record.model_copy(update={"state": state}),
|
|
199
|
+
before,
|
|
200
|
+
AuditEventType.INSTALLATION_PERMISSIONS_UPDATED,
|
|
201
|
+
actor=GITHUB_ACTOR,
|
|
202
|
+
installation_id=installation_id,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
def _apply(
|
|
206
|
+
self,
|
|
207
|
+
record: InstallationRecord,
|
|
208
|
+
before: InstallationState | None,
|
|
209
|
+
audit_type: AuditEventType,
|
|
210
|
+
*,
|
|
211
|
+
affected: int | None = None,
|
|
212
|
+
actor: Actor,
|
|
213
|
+
installation_id: int,
|
|
214
|
+
**data: Any,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Store the installation state, its audit event and any transition notification."""
|
|
217
|
+
now = self._now()
|
|
218
|
+
after = record.state
|
|
219
|
+
with self._store.transaction() as db:
|
|
220
|
+
self._store.upsert_installation_in(db, record)
|
|
221
|
+
if after is InstallationState.DELETED:
|
|
222
|
+
self._store.set_installation_state_in(db, installation_id, after, now)
|
|
223
|
+
repositories = affected
|
|
224
|
+
if repositories is None:
|
|
225
|
+
repositories = int(
|
|
226
|
+
db.execute(
|
|
227
|
+
"SELECT COUNT(*) AS n FROM installation_repositories "
|
|
228
|
+
"WHERE installation_id = ?",
|
|
229
|
+
(installation_id,),
|
|
230
|
+
).fetchone()["n"]
|
|
231
|
+
)
|
|
232
|
+
notification = self._transition_notification(db, record, before, repositories)
|
|
233
|
+
audit = self._store.insert_audit_event(
|
|
234
|
+
db,
|
|
235
|
+
self._audit.build(
|
|
236
|
+
audit_type,
|
|
237
|
+
actor=actor,
|
|
238
|
+
installation_id=installation_id,
|
|
239
|
+
account_id=record.account_id,
|
|
240
|
+
**{k: v for k, v in data.items() if k != "account_id"},
|
|
241
|
+
),
|
|
242
|
+
)
|
|
243
|
+
if notification is not None:
|
|
244
|
+
emit(db, notification, now)
|
|
245
|
+
self._audit.log_stored(audit)
|
|
246
|
+
|
|
247
|
+
@staticmethod
|
|
248
|
+
def _transition_notification(
|
|
249
|
+
db: sqlite3.Connection,
|
|
250
|
+
record: InstallationRecord,
|
|
251
|
+
before: InstallationState | None,
|
|
252
|
+
repositories: int,
|
|
253
|
+
) -> NotificationEvent | None:
|
|
254
|
+
after = record.state
|
|
255
|
+
account = record.account_login
|
|
256
|
+
if before is InstallationState.ACTIVE and after in (
|
|
257
|
+
InstallationState.SUSPENDED,
|
|
258
|
+
InstallationState.DELETED,
|
|
259
|
+
):
|
|
260
|
+
how = "uninstalled" if after is InstallationState.DELETED else "suspended"
|
|
261
|
+
return NotificationEvent(
|
|
262
|
+
type=NotificationType.INSTALLATION_DISCONNECTED,
|
|
263
|
+
account_id=record.account_id,
|
|
264
|
+
severity=Severity.CRITICAL,
|
|
265
|
+
installation_id=record.installation_id,
|
|
266
|
+
resource_type="installation",
|
|
267
|
+
resource_id=str(record.installation_id),
|
|
268
|
+
dedup_key=domain_key(
|
|
269
|
+
NotificationType.INSTALLATION_DISCONNECTED, record.installation_id
|
|
270
|
+
),
|
|
271
|
+
title=f"CommitGuard GitHub installation disconnected: {account}",
|
|
272
|
+
body=(
|
|
273
|
+
f"The CommitGuard GitHub App was {how} for {account}. Affected repositories: "
|
|
274
|
+
f"{repositories}. GitHub enforcement: AT RISK - CommitGuard checks no longer "
|
|
275
|
+
"run, and required checks may block or stop protecting merges. Action: "
|
|
276
|
+
"reinstall or unsuspend the CommitGuard GitHub App."
|
|
277
|
+
),
|
|
278
|
+
metadata={
|
|
279
|
+
"installation_id": record.installation_id,
|
|
280
|
+
"account": account,
|
|
281
|
+
"state": after.value,
|
|
282
|
+
"affected_repositories": repositories,
|
|
283
|
+
"enforcement": "at_risk",
|
|
284
|
+
},
|
|
285
|
+
)
|
|
286
|
+
reconnected = before is InstallationState.SUSPENDED and after is InstallationState.ACTIVE
|
|
287
|
+
if before is None and after is InstallationState.ACTIVE:
|
|
288
|
+
previous = db.execute(
|
|
289
|
+
"SELECT 1 FROM installations WHERE account_id = ? AND installation_id != ? "
|
|
290
|
+
"AND state = 'deleted' LIMIT 1",
|
|
291
|
+
(record.account_id, record.installation_id),
|
|
292
|
+
).fetchone()
|
|
293
|
+
reconnected = previous is not None
|
|
294
|
+
if before is InstallationState.DELETED and after is InstallationState.ACTIVE:
|
|
295
|
+
reconnected = True
|
|
296
|
+
if not reconnected:
|
|
297
|
+
return None
|
|
298
|
+
return NotificationEvent(
|
|
299
|
+
type=NotificationType.INSTALLATION_RECONNECTED,
|
|
300
|
+
account_id=record.account_id,
|
|
301
|
+
severity=Severity.LOW,
|
|
302
|
+
installation_id=record.installation_id,
|
|
303
|
+
resource_type="installation",
|
|
304
|
+
resource_id=str(record.installation_id),
|
|
305
|
+
dedup_key=domain_key(NotificationType.INSTALLATION_RECONNECTED, record.account_id),
|
|
306
|
+
title=f"CommitGuard GitHub installation restored: {account}",
|
|
307
|
+
body=(
|
|
308
|
+
f"The CommitGuard GitHub App is available again for {account}. Affected "
|
|
309
|
+
f"repositories: {repositories}. GitHub enforcement: RESTORED for new events. "
|
|
310
|
+
"Commits pushed while disconnected were not checked by the App; re-run the "
|
|
311
|
+
"CommitGuard check on open pull requests."
|
|
312
|
+
),
|
|
313
|
+
metadata={
|
|
314
|
+
"installation_id": record.installation_id,
|
|
315
|
+
"account": account,
|
|
316
|
+
"affected_repositories": repositories,
|
|
317
|
+
"enforcement": "restored",
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def handle_repositories(self, event: InstallationRepositoriesEvent) -> None:
|
|
322
|
+
now = self._now()
|
|
323
|
+
existing = self._store.get_installation(event.installation_id)
|
|
324
|
+
if existing is not None and existing.state is InstallationState.DELETED:
|
|
325
|
+
return # late event for a removed installation
|
|
326
|
+
if existing is None:
|
|
327
|
+
self._store.upsert_installation(
|
|
328
|
+
InstallationRecord(
|
|
329
|
+
installation_id=event.installation_id,
|
|
330
|
+
account_id=event.account.id,
|
|
331
|
+
account_login=event.account.login,
|
|
332
|
+
account_type=event.account.type,
|
|
333
|
+
repository_selection=event.repository_selection,
|
|
334
|
+
state=InstallationState.ACTIVE,
|
|
335
|
+
created_at=now,
|
|
336
|
+
updated_at=now,
|
|
337
|
+
)
|
|
338
|
+
)
|
|
339
|
+
if event.action is RepositoriesAction.ADDED:
|
|
340
|
+
self._store.add_repositories(event.installation_id, event.added, now)
|
|
341
|
+
self._audit.record(
|
|
342
|
+
AuditEventType.REPOSITORIES_ADDED,
|
|
343
|
+
actor=GITHUB_ACTOR,
|
|
344
|
+
installation_id=event.installation_id,
|
|
345
|
+
repositories=len(event.added),
|
|
346
|
+
)
|
|
347
|
+
account_id = existing.account_id if existing is not None else event.account.id
|
|
348
|
+
self._discovered(account_id, event.installation_id, event.added)
|
|
349
|
+
else:
|
|
350
|
+
removed = [r.id for r in event.removed]
|
|
351
|
+
self._store.remove_repositories(event.installation_id, removed, now)
|
|
352
|
+
for repository_id in removed:
|
|
353
|
+
self._tokens.invalidate(event.installation_id, repository_id)
|
|
354
|
+
self._mirrors.remove_repository(event.installation_id, repository_id)
|
|
355
|
+
self._audit.record(
|
|
356
|
+
AuditEventType.REPOSITORIES_REMOVED,
|
|
357
|
+
actor=GITHUB_ACTOR,
|
|
358
|
+
installation_id=event.installation_id,
|
|
359
|
+
repositories=len(removed),
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# -- authorization --------------------------------------------------- #
|
|
363
|
+
def denial_reason(self, installation_id: int) -> str | None:
|
|
364
|
+
"""A reason to reject events for this installation without calling GitHub."""
|
|
365
|
+
record = self._store.get_installation(installation_id)
|
|
366
|
+
if record is None:
|
|
367
|
+
return None # unknown here (e.g. installed before this service ran): ask GitHub
|
|
368
|
+
if record.state is InstallationState.DELETED:
|
|
369
|
+
return "installation removed"
|
|
370
|
+
if record.state is InstallationState.SUSPENDED:
|
|
371
|
+
return "installation suspended"
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
def ensure_installation_record(self, installation_id: int) -> InstallationRecord:
|
|
375
|
+
"""The stored installation, fetched from GitHub when this service has not seen it.
|
|
376
|
+
|
|
377
|
+
An App installed before the service first ran has no ``installation``
|
|
378
|
+
webhook on record; the dashboard needs its account to attribute data to a
|
|
379
|
+
tenant, so the details are read with the App JWT and stored.
|
|
380
|
+
"""
|
|
381
|
+
existing = self._store.get_installation(installation_id)
|
|
382
|
+
if existing is not None:
|
|
383
|
+
return existing
|
|
384
|
+
info = self._fetch_installation(installation_id)
|
|
385
|
+
record = self._record_from(info, None)
|
|
386
|
+
self._store.upsert_installation(record)
|
|
387
|
+
return record
|
|
388
|
+
|
|
389
|
+
def _fetch_installation(self, installation_id: int) -> InstallationInfo:
|
|
390
|
+
try:
|
|
391
|
+
return self._client.get_installation(self._tokens.app_jwt(), installation_id)
|
|
392
|
+
except GitHubUnauthorizedError:
|
|
393
|
+
raise AuthenticationError("GitHub rejected the App credentials") from None
|
|
394
|
+
except GitHubNotFoundError:
|
|
395
|
+
raise AuthorizationError("GitHub App installation not found") from None
|
|
396
|
+
|
|
397
|
+
def _record_from(
|
|
398
|
+
self, info: InstallationInfo, existing: InstallationRecord | None
|
|
399
|
+
) -> InstallationRecord:
|
|
400
|
+
now = self._now()
|
|
401
|
+
state = InstallationState.SUSPENDED if info.suspended_at else InstallationState.ACTIVE
|
|
402
|
+
return InstallationRecord(
|
|
403
|
+
installation_id=info.id,
|
|
404
|
+
account_id=info.account.id,
|
|
405
|
+
account_login=info.account.login,
|
|
406
|
+
account_type=AccountType(info.account.type),
|
|
407
|
+
repository_selection=info.repository_selection,
|
|
408
|
+
state=state,
|
|
409
|
+
permissions=info.permissions,
|
|
410
|
+
created_at=existing.created_at if existing else now,
|
|
411
|
+
updated_at=now,
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
def sync_repositories(self, installation_id: int, actor: Actor) -> RepositorySync:
|
|
415
|
+
"""Refresh an installation's details and repository list from GitHub.
|
|
416
|
+
|
|
417
|
+
Only the installation named by a caller that is already authorised for it
|
|
418
|
+
is contacted; the repository list comes from GitHub with an installation
|
|
419
|
+
token, never from the caller.
|
|
420
|
+
"""
|
|
421
|
+
existing = self._store.get_installation(installation_id)
|
|
422
|
+
if existing is None or existing.state is InstallationState.DELETED:
|
|
423
|
+
raise AuthorizationError("GitHub App installation removed")
|
|
424
|
+
self._sync_status(installation_id, existing.account_id, "syncing")
|
|
425
|
+
try:
|
|
426
|
+
result = self._sync_repositories(installation_id, existing, actor)
|
|
427
|
+
except Exception as exc:
|
|
428
|
+
self._sync_status(
|
|
429
|
+
installation_id,
|
|
430
|
+
existing.account_id,
|
|
431
|
+
"failed",
|
|
432
|
+
error=f"synchronisation failed ({type(exc).__name__})",
|
|
433
|
+
)
|
|
434
|
+
raise
|
|
435
|
+
self._sync_status(
|
|
436
|
+
installation_id,
|
|
437
|
+
existing.account_id,
|
|
438
|
+
"healthy",
|
|
439
|
+
repositories=len(result.repositories),
|
|
440
|
+
added=len(result.added),
|
|
441
|
+
removed=len(result.removed),
|
|
442
|
+
)
|
|
443
|
+
return result
|
|
444
|
+
|
|
445
|
+
def _sync_status(
|
|
446
|
+
self,
|
|
447
|
+
installation_id: int,
|
|
448
|
+
account_id: int,
|
|
449
|
+
state: str,
|
|
450
|
+
*,
|
|
451
|
+
error: str | None = None,
|
|
452
|
+
repositories: int | None = None,
|
|
453
|
+
added: int | None = None,
|
|
454
|
+
removed: int | None = None,
|
|
455
|
+
) -> None:
|
|
456
|
+
"""Record synchronisation health (separate from "connected")."""
|
|
457
|
+
now = self._now().timestamp()
|
|
458
|
+
with self._store.transaction() as db:
|
|
459
|
+
db.execute(
|
|
460
|
+
"INSERT INTO installation_sync_status (installation_id, account_id, state, "
|
|
461
|
+
"started_at, completed_at, last_success_at, repositories, added, removed, error, "
|
|
462
|
+
"updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
463
|
+
"ON CONFLICT (installation_id) DO UPDATE SET state = excluded.state, "
|
|
464
|
+
"started_at = COALESCE(excluded.started_at, installation_sync_status.started_at), "
|
|
465
|
+
"completed_at = COALESCE(excluded.completed_at, "
|
|
466
|
+
"installation_sync_status.completed_at), last_success_at = "
|
|
467
|
+
"COALESCE(excluded.last_success_at, installation_sync_status.last_success_at), "
|
|
468
|
+
"repositories = COALESCE(excluded.repositories, "
|
|
469
|
+
"installation_sync_status.repositories), added = excluded.added, "
|
|
470
|
+
"removed = excluded.removed, error = excluded.error, "
|
|
471
|
+
"updated_at = excluded.updated_at",
|
|
472
|
+
(
|
|
473
|
+
installation_id,
|
|
474
|
+
account_id,
|
|
475
|
+
state,
|
|
476
|
+
now if state == "syncing" else None,
|
|
477
|
+
now if state != "syncing" else None,
|
|
478
|
+
now if state == "healthy" else None,
|
|
479
|
+
repositories,
|
|
480
|
+
added,
|
|
481
|
+
removed,
|
|
482
|
+
error,
|
|
483
|
+
now,
|
|
484
|
+
),
|
|
485
|
+
)
|
|
486
|
+
|
|
487
|
+
def _sync_repositories(
|
|
488
|
+
self, installation_id: int, existing: InstallationRecord, actor: Actor
|
|
489
|
+
) -> RepositorySync:
|
|
490
|
+
info = self._fetch_installation(installation_id)
|
|
491
|
+
if info.account.id != existing.account_id:
|
|
492
|
+
raise AuthorizationError("installation account mismatch")
|
|
493
|
+
record = self._record_from(info, existing)
|
|
494
|
+
self._store.upsert_installation(record)
|
|
495
|
+
if record.state is InstallationState.SUSPENDED:
|
|
496
|
+
self._tokens.invalidate(installation_id)
|
|
497
|
+
raise AuthorizationError("GitHub App installation suspended")
|
|
498
|
+
before = {r.id: r for r in self._store.list_repositories(installation_id)}
|
|
499
|
+
self._tokens.invalidate(installation_id, None)
|
|
500
|
+
token = self._tokens.token(installation_id, None)
|
|
501
|
+
try:
|
|
502
|
+
listed = self._client.list_installation_repositories(token.token)
|
|
503
|
+
finally:
|
|
504
|
+
self._tokens.invalidate(installation_id, None)
|
|
505
|
+
now = self._now()
|
|
506
|
+
repositories = tuple(info.ref for info in listed)
|
|
507
|
+
self._store.replace_repositories(installation_id, repositories, now)
|
|
508
|
+
for repository_info in listed:
|
|
509
|
+
self._store.set_repository_details(
|
|
510
|
+
installation_id,
|
|
511
|
+
repository_info.id,
|
|
512
|
+
default_branch=repository_info.default_branch,
|
|
513
|
+
private=repository_info.private,
|
|
514
|
+
archived=repository_info.archived,
|
|
515
|
+
)
|
|
516
|
+
after = {r.id: r for r in repositories}
|
|
517
|
+
added = tuple(r for rid, r in sorted(after.items()) if rid not in before)
|
|
518
|
+
removed = tuple(r for rid, r in sorted(before.items()) if rid not in after)
|
|
519
|
+
for repository in removed:
|
|
520
|
+
self._tokens.invalidate(installation_id, repository.id)
|
|
521
|
+
self._mirrors.remove_repository(installation_id, repository.id)
|
|
522
|
+
self._audit.record(
|
|
523
|
+
AuditEventType.REPOSITORIES_SYNCED,
|
|
524
|
+
actor=actor,
|
|
525
|
+
installation_id=installation_id,
|
|
526
|
+
repositories=len(repositories),
|
|
527
|
+
added=len(added),
|
|
528
|
+
removed=len(removed),
|
|
529
|
+
)
|
|
530
|
+
self._discovered(record.account_id, installation_id, repositories)
|
|
531
|
+
return RepositorySync(installation_id, repositories, added, removed, now)
|
|
532
|
+
|
|
533
|
+
def authorize(self, installation_id: int, repository: RepositoryRef) -> AuthorizedRepository:
|
|
534
|
+
reason = self.denial_reason(installation_id)
|
|
535
|
+
if reason is not None:
|
|
536
|
+
raise AuthorizationError(f"GitHub App {reason}")
|
|
537
|
+
try:
|
|
538
|
+
self.ensure_installation_record(installation_id)
|
|
539
|
+
except GitHubAPIError as exc:
|
|
540
|
+
log.warning("installation_details_unavailable", category=exc.category.value)
|
|
541
|
+
token = self._tokens.token(installation_id, repository.id)
|
|
542
|
+
try:
|
|
543
|
+
info = self._client.get_repository(token.token, repository.id)
|
|
544
|
+
except GitHubUnauthorizedError:
|
|
545
|
+
self._tokens.invalidate(installation_id, repository.id)
|
|
546
|
+
raise AuthenticationError("GitHub rejected the installation token") from None
|
|
547
|
+
except (GitHubNotFoundError, GitHubForbiddenError):
|
|
548
|
+
self._tokens.invalidate(installation_id, repository.id)
|
|
549
|
+
raise AuthorizationError("the installation cannot access this repository") from None
|
|
550
|
+
if info.id != repository.id:
|
|
551
|
+
raise AuthorizationError("repository identity mismatch")
|
|
552
|
+
canonical = info.ref
|
|
553
|
+
self._store.add_repositories(installation_id, [canonical], self._now())
|
|
554
|
+
self._store.set_repository_details(
|
|
555
|
+
installation_id,
|
|
556
|
+
canonical.id,
|
|
557
|
+
default_branch=info.default_branch,
|
|
558
|
+
private=info.private,
|
|
559
|
+
archived=info.archived,
|
|
560
|
+
)
|
|
561
|
+
return AuthorizedRepository(
|
|
562
|
+
installation_id=installation_id,
|
|
563
|
+
repository=canonical,
|
|
564
|
+
token=token,
|
|
565
|
+
default_branch=info.default_branch,
|
|
566
|
+
)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Escaping of untrusted text for GitHub-rendered Markdown (job summaries, Check Runs)."""
|
|
2
|
+
|
|
3
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
4
|
+
from commitguard.security.secrets import redact
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def escape_markdown(value: str, limit: int = 200) -> str:
|
|
8
|
+
"""Escape untrusted text for a Markdown paragraph or table cell.
|
|
9
|
+
|
|
10
|
+
Control and bidi characters are made visible, credential-shaped strings
|
|
11
|
+
are redacted, Markdown syntax is backslash-escaped (links, images, emphasis,
|
|
12
|
+
headings cannot be injected) and HTML is entity-encoded.
|
|
13
|
+
"""
|
|
14
|
+
text = sanitize_for_terminal(redact(value), max_length=limit)
|
|
15
|
+
for char in "\\`*_{}[]()#+-.!~": # Markdown first: entities below contain '#'
|
|
16
|
+
text = text.replace(char, "\\" + char)
|
|
17
|
+
for char, entity in (("&", "&"), ("<", "<"), (">", ">"), ("|", "|")):
|
|
18
|
+
text = text.replace(char, entity)
|
|
19
|
+
return text
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""GitHub App permissions: what CommitGuard needs, and nothing more.
|
|
2
|
+
|
|
3
|
+
================= ====== =====================================================
|
|
4
|
+
Permission Level Why
|
|
5
|
+
================= ====== =====================================================
|
|
6
|
+
``metadata`` read mandatory for every App; repository lookup by ID
|
|
7
|
+
``contents`` read fetch commit objects (no file contents are kept) and
|
|
8
|
+
receive ``push`` events
|
|
9
|
+
``pull_requests`` read receive ``pull_request`` events; confirm the PR head
|
|
10
|
+
before publishing a result
|
|
11
|
+
``checks`` write create and update the CommitGuard Check Run; receive
|
|
12
|
+
``check_run`` / ``check_suite`` re-run requests
|
|
13
|
+
================= ====== =====================================================
|
|
14
|
+
|
|
15
|
+
Optional (requested only for the feature that needs it):
|
|
16
|
+
|
|
17
|
+
================= ====== =====================================================
|
|
18
|
+
``merge_queues`` read receive ``merge_group`` events, so CommitGuard can
|
|
19
|
+
validate the exact commit a merge queue tests
|
|
20
|
+
================= ====== =====================================================
|
|
21
|
+
|
|
22
|
+
An installation without an optional permission keeps working; the feature is
|
|
23
|
+
reported as unavailable. Optional permissions are never requested for
|
|
24
|
+
installation tokens: scanning a merge group needs only ``contents: read``.
|
|
25
|
+
|
|
26
|
+
CommitGuard never needs write access to contents, administration, actions,
|
|
27
|
+
workflows or members: it detects and reports, it never modifies a repository.
|
|
28
|
+
Installation tokens are additionally *down-scoped* to exactly the required
|
|
29
|
+
permissions and to the single repository being scanned.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from collections.abc import Mapping
|
|
33
|
+
from types import MappingProxyType
|
|
34
|
+
|
|
35
|
+
REQUIRED_PERMISSIONS: Mapping[str, str] = MappingProxyType(
|
|
36
|
+
{"checks": "write", "contents": "read", "metadata": "read", "pull_requests": "read"}
|
|
37
|
+
)
|
|
38
|
+
OPTIONAL_PERMISSIONS: Mapping[str, str] = MappingProxyType({"merge_queues": "read"})
|
|
39
|
+
WEBHOOK_EVENTS = ("installation", "installation_repositories", "pull_request", "push")
|
|
40
|
+
#: Subscriptions for Phase 7 features: GitHub "Re-run" buttons and merge queues.
|
|
41
|
+
OPTIONAL_WEBHOOK_EVENTS = ("check_run", "check_suite", "merge_group")
|
|
42
|
+
|
|
43
|
+
_LEVELS = {"none": 0, "read": 1, "write": 2, "admin": 3}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def level_rank(level: str | None) -> int:
|
|
47
|
+
return _LEVELS.get(level or "none", 0)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def missing_permissions(
|
|
51
|
+
granted: Mapping[str, str], required: Mapping[str, str] = REQUIRED_PERMISSIONS
|
|
52
|
+
) -> dict[str, str]:
|
|
53
|
+
"""Required permissions that ``granted`` does not satisfy (name -> required level)."""
|
|
54
|
+
return {
|
|
55
|
+
name: level
|
|
56
|
+
for name, level in sorted(required.items())
|
|
57
|
+
if level_rank(granted.get(name)) < level_rank(level)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def excessive_permissions(
|
|
62
|
+
granted: Mapping[str, str], required: Mapping[str, str] = REQUIRED_PERMISSIONS
|
|
63
|
+
) -> dict[str, str]:
|
|
64
|
+
"""Granted permissions above what CommitGuard needs or can use (name -> granted level)."""
|
|
65
|
+
usable = {**OPTIONAL_PERMISSIONS, **required}
|
|
66
|
+
return {
|
|
67
|
+
name: level
|
|
68
|
+
for name, level in sorted(granted.items())
|
|
69
|
+
if level_rank(level) > level_rank(usable.get(name))
|
|
70
|
+
}
|