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,393 @@
|
|
|
1
|
+
"""Write operations the dashboard can request.
|
|
2
|
+
|
|
3
|
+
Each command resolves its target inside the caller's access scope first, so a
|
|
4
|
+
resource from another tenant is "not found" before any permission question is
|
|
5
|
+
asked; then it checks the permission for that resource's account; then it
|
|
6
|
+
changes state and records an audit event in the same transaction.
|
|
7
|
+
|
|
8
|
+
None of these commands can change a security decision already made: they cannot
|
|
9
|
+
mark a violation resolved, turn a failed GitHub check into a success, or touch
|
|
10
|
+
Git history. Pausing monitoring and weakening policy are the only operations
|
|
11
|
+
that can reduce enforcement; both need explicit confirmation and a recent
|
|
12
|
+
sign-in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from datetime import UTC, datetime
|
|
18
|
+
|
|
19
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
20
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
21
|
+
from commitguard.controlplane.errors import (
|
|
22
|
+
ConfirmationRequiredError,
|
|
23
|
+
ConflictError,
|
|
24
|
+
InputValidationError,
|
|
25
|
+
NotFoundError,
|
|
26
|
+
PermissionDeniedError,
|
|
27
|
+
ReauthenticationRequiredError,
|
|
28
|
+
UpstreamUnavailableError,
|
|
29
|
+
)
|
|
30
|
+
from commitguard.controlplane.policies import REAUTHENTICATION_WINDOW
|
|
31
|
+
from commitguard.controlplane.queries import DashboardQueries
|
|
32
|
+
from commitguard.controlplane.results import clean_text
|
|
33
|
+
from commitguard.controlplane.views import SyncResult
|
|
34
|
+
from commitguard.core.result import Severity
|
|
35
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
36
|
+
from commitguard.github.enforcement_status import EnforcementProbe
|
|
37
|
+
from commitguard.github.errors import AuthorizationError, GitHubAPIError
|
|
38
|
+
from commitguard.github.identifiers import RepositoryRef
|
|
39
|
+
from commitguard.github.installations import InstallationService
|
|
40
|
+
from commitguard.github.storage import ScanTrigger, SqliteStateStore
|
|
41
|
+
from commitguard.notifications.deduplication import domain_key
|
|
42
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
43
|
+
from commitguard.notifications.outbox import account_for_installation, emit
|
|
44
|
+
from commitguard.observability.logging import get_logger
|
|
45
|
+
from commitguard.services.audit import AuditService
|
|
46
|
+
|
|
47
|
+
log = get_logger(__name__)
|
|
48
|
+
|
|
49
|
+
MAX_NOTE_CHARS = 500
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _actor(principal: Principal) -> Actor:
|
|
53
|
+
return Actor.user(principal.user_id, principal.login)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _note(value: object, field: str, *, required: bool = False) -> str | None:
|
|
57
|
+
if value is None or (isinstance(value, str) and not value.strip()):
|
|
58
|
+
if required:
|
|
59
|
+
raise InputValidationError("A reason is required.", field=field)
|
|
60
|
+
return None
|
|
61
|
+
if not isinstance(value, str) or len(value) > MAX_NOTE_CHARS:
|
|
62
|
+
raise InputValidationError(
|
|
63
|
+
f"{field} must be text of at most {MAX_NOTE_CHARS} characters", field=field
|
|
64
|
+
)
|
|
65
|
+
return clean_text(value.strip(), MAX_NOTE_CHARS)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class ControlPlaneCommands:
|
|
69
|
+
def __init__(
|
|
70
|
+
self,
|
|
71
|
+
store: SqliteStateStore,
|
|
72
|
+
queries: DashboardQueries,
|
|
73
|
+
audit: AuditService,
|
|
74
|
+
installations: InstallationService,
|
|
75
|
+
probe: EnforcementProbe,
|
|
76
|
+
*,
|
|
77
|
+
enqueue: Callable[[str], bool],
|
|
78
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
79
|
+
) -> None:
|
|
80
|
+
self._store = store
|
|
81
|
+
self._queries = queries
|
|
82
|
+
self._audit = audit
|
|
83
|
+
self._installations = installations
|
|
84
|
+
self._probe = probe
|
|
85
|
+
self._enqueue = enqueue
|
|
86
|
+
self._now = now
|
|
87
|
+
|
|
88
|
+
# ------------------------------------------------------------------ #
|
|
89
|
+
# Violations
|
|
90
|
+
# ------------------------------------------------------------------ #
|
|
91
|
+
def acknowledge_violation(self, principal: Principal, violation_id: str, note: object) -> None:
|
|
92
|
+
scope = principal.scope(Permission.VIOLATIONS_READ)
|
|
93
|
+
row = self._queries.violation_row(scope, violation_id)
|
|
94
|
+
if row is None:
|
|
95
|
+
raise NotFoundError()
|
|
96
|
+
account_id = int(row["account_id"])
|
|
97
|
+
if not principal.can(Permission.VIOLATIONS_MANAGE, account_id):
|
|
98
|
+
raise PermissionDeniedError()
|
|
99
|
+
text = _note(note, "note")
|
|
100
|
+
now = self._now().timestamp()
|
|
101
|
+
with self._store.transaction() as db:
|
|
102
|
+
cursor = db.execute(
|
|
103
|
+
"UPDATE violations SET acknowledged_at = ?, acknowledged_by_id = ?, "
|
|
104
|
+
"acknowledged_by_login = ?, acknowledgement_note = ?, updated_at = ? "
|
|
105
|
+
"WHERE violation_id = ? AND status = 'open'",
|
|
106
|
+
(now, principal.user_id, principal.login, text, now, violation_id),
|
|
107
|
+
)
|
|
108
|
+
if cursor.rowcount != 1:
|
|
109
|
+
raise ConflictError("Only open violations can be acknowledged.")
|
|
110
|
+
event = self._store.insert_audit_event(
|
|
111
|
+
db,
|
|
112
|
+
self._audit.build(
|
|
113
|
+
AuditEventType.VIOLATION_ACKNOWLEDGED,
|
|
114
|
+
actor=_actor(principal),
|
|
115
|
+
installation_id=row["installation_id"],
|
|
116
|
+
repository_id=row["repository_id"],
|
|
117
|
+
violation=violation_id,
|
|
118
|
+
rule=row["rule_id"],
|
|
119
|
+
note=text,
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
self._audit.log_stored(event)
|
|
123
|
+
|
|
124
|
+
def remove_acknowledgement(self, principal: Principal, violation_id: str) -> None:
|
|
125
|
+
scope = principal.scope(Permission.VIOLATIONS_READ)
|
|
126
|
+
row = self._queries.violation_row(scope, violation_id)
|
|
127
|
+
if row is None:
|
|
128
|
+
raise NotFoundError()
|
|
129
|
+
if not principal.can(Permission.VIOLATIONS_MANAGE, int(row["account_id"])):
|
|
130
|
+
raise PermissionDeniedError()
|
|
131
|
+
with self._store.transaction() as db:
|
|
132
|
+
cursor = db.execute(
|
|
133
|
+
"UPDATE violations SET acknowledged_at = NULL, acknowledged_by_id = NULL, "
|
|
134
|
+
"acknowledged_by_login = NULL, acknowledgement_note = NULL, updated_at = ? "
|
|
135
|
+
"WHERE violation_id = ? AND acknowledged_at IS NOT NULL",
|
|
136
|
+
(self._now().timestamp(), violation_id),
|
|
137
|
+
)
|
|
138
|
+
if cursor.rowcount != 1:
|
|
139
|
+
raise ConflictError("This violation is not acknowledged.")
|
|
140
|
+
event = self._store.insert_audit_event(
|
|
141
|
+
db,
|
|
142
|
+
self._audit.build(
|
|
143
|
+
AuditEventType.VIOLATION_ACKNOWLEDGEMENT_REMOVED,
|
|
144
|
+
actor=_actor(principal),
|
|
145
|
+
installation_id=row["installation_id"],
|
|
146
|
+
repository_id=row["repository_id"],
|
|
147
|
+
violation=violation_id,
|
|
148
|
+
rule=row["rule_id"],
|
|
149
|
+
),
|
|
150
|
+
)
|
|
151
|
+
self._audit.log_stored(event)
|
|
152
|
+
|
|
153
|
+
# ------------------------------------------------------------------ #
|
|
154
|
+
# Repositories
|
|
155
|
+
# ------------------------------------------------------------------ #
|
|
156
|
+
def _manageable_repository(self, principal: Principal, repository_id: int): # type: ignore[no-untyped-def]
|
|
157
|
+
row = self._queries.repository_ref(
|
|
158
|
+
principal.scope(Permission.REPOSITORIES_READ), repository_id
|
|
159
|
+
)
|
|
160
|
+
if row is None:
|
|
161
|
+
raise NotFoundError()
|
|
162
|
+
if not principal.can(Permission.REPOSITORIES_MANAGE, int(row["account_id"])):
|
|
163
|
+
raise PermissionDeniedError()
|
|
164
|
+
return row
|
|
165
|
+
|
|
166
|
+
def set_monitoring(
|
|
167
|
+
self,
|
|
168
|
+
principal: Principal,
|
|
169
|
+
repository_id: int,
|
|
170
|
+
*,
|
|
171
|
+
enabled: object,
|
|
172
|
+
confirm: object,
|
|
173
|
+
reason: object,
|
|
174
|
+
) -> None:
|
|
175
|
+
if not isinstance(enabled, bool):
|
|
176
|
+
raise InputValidationError("enabled must be true or false", field="enabled")
|
|
177
|
+
row = self._manageable_repository(principal, repository_id)
|
|
178
|
+
installation_id = int(row["installation_id"])
|
|
179
|
+
now = self._now()
|
|
180
|
+
text = _note(reason, "reason", required=not enabled)
|
|
181
|
+
if not enabled:
|
|
182
|
+
if confirm is not True:
|
|
183
|
+
raise ConfirmationRequiredError(
|
|
184
|
+
"Pausing monitoring stops CommitGuard checks for this repository and must be "
|
|
185
|
+
"confirmed."
|
|
186
|
+
)
|
|
187
|
+
if now - principal.authenticated_at > REAUTHENTICATION_WINDOW:
|
|
188
|
+
raise ReauthenticationRequiredError()
|
|
189
|
+
with self._store.transaction() as db:
|
|
190
|
+
current = db.execute(
|
|
191
|
+
"SELECT monitoring_enabled FROM repository_settings WHERE installation_id = ? "
|
|
192
|
+
"AND repository_id = ?",
|
|
193
|
+
(installation_id, repository_id),
|
|
194
|
+
).fetchone()
|
|
195
|
+
if (current is None and enabled) or (
|
|
196
|
+
current is not None and bool(current["monitoring_enabled"]) == enabled
|
|
197
|
+
):
|
|
198
|
+
raise ConflictError(
|
|
199
|
+
"Monitoring is already " + ("enabled." if enabled else "paused.")
|
|
200
|
+
)
|
|
201
|
+
db.execute(
|
|
202
|
+
"INSERT INTO repository_settings (installation_id, repository_id, "
|
|
203
|
+
"monitoring_enabled, updated_at, updated_by_login) VALUES (?, ?, ?, ?, ?) "
|
|
204
|
+
"ON CONFLICT (installation_id, repository_id) DO UPDATE SET "
|
|
205
|
+
"monitoring_enabled = excluded.monitoring_enabled, "
|
|
206
|
+
"updated_at = excluded.updated_at, updated_by_login = excluded.updated_by_login",
|
|
207
|
+
(installation_id, repository_id, int(enabled), now.timestamp(), principal.login),
|
|
208
|
+
)
|
|
209
|
+
if not enabled:
|
|
210
|
+
db.execute(
|
|
211
|
+
"UPDATE scan_jobs SET state = 'cancelled', message = 'monitoring paused', "
|
|
212
|
+
"updated_at = ?, completed_at = ? WHERE installation_id = ? "
|
|
213
|
+
"AND repository_id = ? AND state = 'queued'",
|
|
214
|
+
(now.timestamp(), now.timestamp(), installation_id, repository_id),
|
|
215
|
+
)
|
|
216
|
+
event = self._store.insert_audit_event(
|
|
217
|
+
db,
|
|
218
|
+
self._audit.build(
|
|
219
|
+
AuditEventType.REPOSITORY_MONITORING_ENABLED
|
|
220
|
+
if enabled
|
|
221
|
+
else AuditEventType.REPOSITORY_MONITORING_DISABLED,
|
|
222
|
+
actor=_actor(principal),
|
|
223
|
+
installation_id=installation_id,
|
|
224
|
+
repository_id=repository_id,
|
|
225
|
+
repository=f"{row['owner']}/{row['name']}",
|
|
226
|
+
reason=text,
|
|
227
|
+
),
|
|
228
|
+
)
|
|
229
|
+
self._audit.log_stored(event)
|
|
230
|
+
|
|
231
|
+
def refresh_enforcement(self, principal: Principal, repository_id: int) -> None:
|
|
232
|
+
row = self._manageable_repository(principal, repository_id)
|
|
233
|
+
installation_id = int(row["installation_id"])
|
|
234
|
+
repository = RepositoryRef(id=repository_id, owner=row["owner"], name=row["name"])
|
|
235
|
+
try:
|
|
236
|
+
authorized = self._installations.authorize(installation_id, repository)
|
|
237
|
+
evidence = self._probe.probe(
|
|
238
|
+
authorized.token.token, authorized.repository, authorized.default_branch
|
|
239
|
+
)
|
|
240
|
+
except AuthorizationError as exc:
|
|
241
|
+
raise ConflictError(f"GitHub denied access: {exc}") from None
|
|
242
|
+
except GitHubAPIError:
|
|
243
|
+
raise UpstreamUnavailableError(
|
|
244
|
+
"GitHub could not be reached. Try again later."
|
|
245
|
+
) from None
|
|
246
|
+
except CommitGuardError as exc:
|
|
247
|
+
log.warning("enforcement_probe_failed", error_type=type(exc).__name__)
|
|
248
|
+
raise UpstreamUnavailableError("The enforcement status could not be checked.") from None
|
|
249
|
+
with self._store.transaction() as db:
|
|
250
|
+
previous = db.execute(
|
|
251
|
+
"SELECT branch_protection FROM enforcement_status WHERE installation_id = ? "
|
|
252
|
+
"AND repository_id = ?",
|
|
253
|
+
(installation_id, repository_id),
|
|
254
|
+
).fetchone()
|
|
255
|
+
db.execute(
|
|
256
|
+
"INSERT INTO enforcement_status (installation_id, repository_id, checked_at, "
|
|
257
|
+
"branch, actions, actions_detail, branch_protection, required_checks, "
|
|
258
|
+
"branch_protection_detail, merge_queue, merge_queue_detail) "
|
|
259
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) "
|
|
260
|
+
"ON CONFLICT (installation_id, repository_id) DO UPDATE SET "
|
|
261
|
+
"checked_at = excluded.checked_at, branch = excluded.branch, "
|
|
262
|
+
"actions = excluded.actions, actions_detail = excluded.actions_detail, "
|
|
263
|
+
"branch_protection = excluded.branch_protection, "
|
|
264
|
+
"required_checks = excluded.required_checks, "
|
|
265
|
+
"branch_protection_detail = excluded.branch_protection_detail, "
|
|
266
|
+
"merge_queue = excluded.merge_queue, "
|
|
267
|
+
"merge_queue_detail = excluded.merge_queue_detail",
|
|
268
|
+
(
|
|
269
|
+
installation_id,
|
|
270
|
+
repository_id,
|
|
271
|
+
evidence.checked_at.timestamp(),
|
|
272
|
+
clean_text(evidence.branch, 256) if evidence.branch else None,
|
|
273
|
+
evidence.actions,
|
|
274
|
+
clean_text(evidence.actions_detail, 500),
|
|
275
|
+
evidence.branch_protection,
|
|
276
|
+
json.dumps([clean_text(c, 200) for c in evidence.required_checks]),
|
|
277
|
+
clean_text(evidence.branch_protection_detail, 500),
|
|
278
|
+
evidence.merge_queue,
|
|
279
|
+
clean_text(evidence.merge_queue_detail, 500),
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
event = self._store.insert_audit_event(
|
|
283
|
+
db,
|
|
284
|
+
self._audit.build(
|
|
285
|
+
AuditEventType.ENFORCEMENT_STATUS_CHECKED,
|
|
286
|
+
actor=_actor(principal),
|
|
287
|
+
installation_id=installation_id,
|
|
288
|
+
repository_id=repository_id,
|
|
289
|
+
repository=authorized.repository.full_name,
|
|
290
|
+
actions=evidence.actions,
|
|
291
|
+
branch_protection=evidence.branch_protection,
|
|
292
|
+
merge_queue=evidence.merge_queue,
|
|
293
|
+
),
|
|
294
|
+
)
|
|
295
|
+
was_required = previous is not None and previous["branch_protection"] == "required"
|
|
296
|
+
account_id = account_for_installation(db, installation_id)
|
|
297
|
+
if was_required and evidence.branch_protection != "required" and account_id:
|
|
298
|
+
emit(
|
|
299
|
+
db,
|
|
300
|
+
NotificationEvent(
|
|
301
|
+
type=NotificationType.REPOSITORY_UNPROTECTED,
|
|
302
|
+
account_id=account_id,
|
|
303
|
+
severity=Severity.HIGH,
|
|
304
|
+
installation_id=installation_id,
|
|
305
|
+
repository_id=repository_id,
|
|
306
|
+
resource_type="repository",
|
|
307
|
+
resource_id=str(repository_id),
|
|
308
|
+
dedup_key=domain_key(
|
|
309
|
+
NotificationType.REPOSITORY_UNPROTECTED, account_id, repository_id
|
|
310
|
+
),
|
|
311
|
+
title=f"Repository no longer protected: {authorized.repository.full_name}",
|
|
312
|
+
body=(
|
|
313
|
+
f"GitHub no longer requires a CommitGuard check on "
|
|
314
|
+
f"{authorized.repository.full_name}"
|
|
315
|
+
f"{' (' + evidence.branch + ')' if evidence.branch else ''}. "
|
|
316
|
+
"Failing CommitGuard checks no longer block merges. "
|
|
317
|
+
f"{clean_text(evidence.branch_protection_detail, 300)}"
|
|
318
|
+
),
|
|
319
|
+
metadata={"branch_protection": evidence.branch_protection},
|
|
320
|
+
),
|
|
321
|
+
self._now(),
|
|
322
|
+
)
|
|
323
|
+
self._audit.log_stored(event)
|
|
324
|
+
|
|
325
|
+
# ------------------------------------------------------------------ #
|
|
326
|
+
# GitHub installations
|
|
327
|
+
# ------------------------------------------------------------------ #
|
|
328
|
+
def sync_installation(self, principal: Principal, installation_id: int) -> SyncResult:
|
|
329
|
+
scope = principal.scope(Permission.REPOSITORIES_READ)
|
|
330
|
+
if installation_id not in scope.installation_ids:
|
|
331
|
+
raise NotFoundError()
|
|
332
|
+
account_id = principal.installations[installation_id]
|
|
333
|
+
if not principal.can(Permission.GITHUB_MANAGE, account_id):
|
|
334
|
+
raise PermissionDeniedError()
|
|
335
|
+
try:
|
|
336
|
+
sync = self._installations.sync_repositories(installation_id, _actor(principal))
|
|
337
|
+
except AuthorizationError as exc:
|
|
338
|
+
raise ConflictError(f"GitHub denied access: {exc}") from None
|
|
339
|
+
except GitHubAPIError:
|
|
340
|
+
raise UpstreamUnavailableError(
|
|
341
|
+
"GitHub could not be reached. Try again later."
|
|
342
|
+
) from None
|
|
343
|
+
return SyncResult(
|
|
344
|
+
installation_id=installation_id,
|
|
345
|
+
repositories=len(sync.repositories),
|
|
346
|
+
added=tuple(r.full_name for r in sync.added),
|
|
347
|
+
removed=tuple(r.full_name for r in sync.removed),
|
|
348
|
+
synced_at=sync.synced_at,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------------ #
|
|
352
|
+
# Scans
|
|
353
|
+
# ------------------------------------------------------------------ #
|
|
354
|
+
def request_rescan(self, principal: Principal, scan_id: str) -> str:
|
|
355
|
+
"""Queue a new execution of exactly the commits a stored scan covered.
|
|
356
|
+
|
|
357
|
+
The repository, commits and event come from the stored scan - never from
|
|
358
|
+
the request - and the new execution goes through the same worker path as a
|
|
359
|
+
webhook (authorization against GitHub, current effective policy, check
|
|
360
|
+
ownership). It is recorded as execution N+1 of the same scan with trigger
|
|
361
|
+
``manual``; earlier executions are unchanged. Callers cannot choose a
|
|
362
|
+
historical policy version.
|
|
363
|
+
"""
|
|
364
|
+
row = self._queries.scan_row_for_command(principal.scope(Permission.SCANS_READ), scan_id)
|
|
365
|
+
if row is None:
|
|
366
|
+
raise NotFoundError()
|
|
367
|
+
allowed, reason = self._queries.rescan_eligibility(row, principal)
|
|
368
|
+
if not allowed:
|
|
369
|
+
if not principal.can(Permission.SCANS_TRIGGER, int(row["account_id"] or 0)):
|
|
370
|
+
raise PermissionDeniedError()
|
|
371
|
+
raise ConflictError(reason or "This scan cannot be repeated.")
|
|
372
|
+
job = self._store.get_job(scan_id)
|
|
373
|
+
if job is None: # pragma: no cover - the row was just read
|
|
374
|
+
raise NotFoundError()
|
|
375
|
+
new_job, created = self._store.create_execution(
|
|
376
|
+
job, trigger=ScanTrigger.MANUAL, now=self._now(), requested_by=principal.login
|
|
377
|
+
)
|
|
378
|
+
if not created:
|
|
379
|
+
raise ConflictError("A scan of these commits is already queued or running.")
|
|
380
|
+
self._audit.record(
|
|
381
|
+
AuditEventType.SCAN_REQUESTED,
|
|
382
|
+
actor=_actor(principal),
|
|
383
|
+
installation_id=job.installation_id,
|
|
384
|
+
repository_id=job.repository.id,
|
|
385
|
+
repository=job.repository.full_name,
|
|
386
|
+
head_sha=job.head_sha,
|
|
387
|
+
job=new_job.job_id,
|
|
388
|
+
previous_scan=scan_id,
|
|
389
|
+
execution=new_job.execution,
|
|
390
|
+
trigger=ScanTrigger.MANUAL.value,
|
|
391
|
+
)
|
|
392
|
+
self._enqueue(new_job.job_id) # if the queue is full, recovery picks it up
|
|
393
|
+
return new_job.job_id
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Control plane errors. The API layer maps each to one HTTP status and error code.
|
|
2
|
+
|
|
3
|
+
Messages are written for the person using the dashboard and never contain
|
|
4
|
+
secrets, SQL, stack traces or data from another tenant.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ControlPlaneError(CommitGuardError):
|
|
11
|
+
code = "INTERNAL_ERROR"
|
|
12
|
+
status = 500
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NotFoundError(ControlPlaneError):
|
|
16
|
+
"""Missing, or belongs to a tenant the caller cannot see (never distinguished)."""
|
|
17
|
+
|
|
18
|
+
code = "NOT_FOUND"
|
|
19
|
+
status = 404
|
|
20
|
+
|
|
21
|
+
def __init__(self, message: str = "The requested resource was not found.") -> None:
|
|
22
|
+
super().__init__(message)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class PermissionDeniedError(ControlPlaneError):
|
|
26
|
+
"""The caller can see the resource but their role does not allow the operation."""
|
|
27
|
+
|
|
28
|
+
code = "FORBIDDEN"
|
|
29
|
+
status = 403
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str = "You do not have permission to perform this action.") -> None:
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InputValidationError(ControlPlaneError):
|
|
36
|
+
code = "VALIDATION_ERROR"
|
|
37
|
+
status = 400
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, *, field: str | None = None) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.field = field
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConflictError(ControlPlaneError):
|
|
45
|
+
code = "CONFLICT"
|
|
46
|
+
status = 409
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class PolicyIntegrityError(ControlPlaneError):
|
|
50
|
+
"""A stored policy version does not match its fingerprint or is not a valid policy."""
|
|
51
|
+
|
|
52
|
+
code = "POLICY_VERSION_INVALID"
|
|
53
|
+
status = 422
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class ConfirmationRequiredError(ControlPlaneError):
|
|
57
|
+
"""A change weakens enforcement and was not explicitly confirmed."""
|
|
58
|
+
|
|
59
|
+
code = "CONFIRMATION_REQUIRED"
|
|
60
|
+
status = 409
|
|
61
|
+
|
|
62
|
+
def __init__(self, message: str, *, changes: tuple[str, ...] = ()) -> None:
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.changes = changes # what the change relaxes, one entry per control
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ApprovalRequiredError(ControlPlaneError):
|
|
68
|
+
"""The organization requires policy changes to be approved before publication."""
|
|
69
|
+
|
|
70
|
+
code = "APPROVAL_REQUIRED"
|
|
71
|
+
status = 409
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class ReauthenticationRequiredError(ControlPlaneError):
|
|
75
|
+
code = "REAUTHENTICATION_REQUIRED"
|
|
76
|
+
status = 401
|
|
77
|
+
|
|
78
|
+
def __init__(
|
|
79
|
+
self, message: str = "Sign in again to confirm your identity before this change."
|
|
80
|
+
) -> None:
|
|
81
|
+
super().__init__(message)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class UpstreamUnavailableError(ControlPlaneError):
|
|
85
|
+
"""GitHub could not be reached or refused the request."""
|
|
86
|
+
|
|
87
|
+
code = "GITHUB_UNAVAILABLE"
|
|
88
|
+
status = 502
|