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,935 @@
|
|
|
1
|
+
"""The CommitGuard GitHub App service.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
GitHub --HTTPS webhook--> WSGI endpoint (POST /webhooks/github)
|
|
6
|
+
rate limit -> size/content-type -> signature -> headers -> JSON
|
|
7
|
+
-> normalise -> event record (delivery ID: new / duplicate / retry)
|
|
8
|
+
-> installation events: apply (state + audit + notification, one transaction)
|
|
9
|
+
-> push / pull_request / merge_group: store job, enqueue
|
|
10
|
+
-> check_run / check_suite "rerequested": new execution of the stored scan
|
|
11
|
+
<- 2xx within milliseconds (no scanning on the request path)
|
|
12
|
+
|
|
13
|
+
worker threads: queue -> ScanWorker -> Check Run
|
|
14
|
+
notification thread: outbox -> inbox / deliveries -> e-mail, webhooks (retries)
|
|
15
|
+
maintenance: re-enqueue abandoned jobs, recovery (failed deliveries,
|
|
16
|
+
infrastructure retries), retention purge
|
|
17
|
+
|
|
18
|
+
Event records: every verified delivery is stored with a processing status
|
|
19
|
+
(``processing`` -> ``processed`` / ``ignored`` / ``failed``). GitHub reuses the
|
|
20
|
+
delivery ID when a delivery is redelivered: a processed delivery is answered as
|
|
21
|
+
a duplicate, while a failed or abandoned one is processed again, so an outage
|
|
22
|
+
while handling an event does not lose it.
|
|
23
|
+
|
|
24
|
+
The WSGI application has no framework dependency. ``commitguard github serve``
|
|
25
|
+
runs it with a small threaded development server; production deployments put
|
|
26
|
+
a WSGI server behind a TLS-terminating reverse proxy (docs/deployment.md).
|
|
27
|
+
The service itself never serves plain HTTP to the internet.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import threading
|
|
32
|
+
import time
|
|
33
|
+
from collections.abc import Callable, Iterable, Mapping, Sequence
|
|
34
|
+
from dataclasses import dataclass, field
|
|
35
|
+
from datetime import UTC, datetime, timedelta
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
from wsgiref.types import StartResponse, WSGIEnvironment
|
|
39
|
+
|
|
40
|
+
from commitguard.audit.models import GITHUB_ACTOR, AuditEventType
|
|
41
|
+
from commitguard.config.sources import MandatoryPolicy, load_mandatory_policy
|
|
42
|
+
from commitguard.controlplane.policies import OrganizationPolicyService
|
|
43
|
+
from commitguard.controlplane.results import ScanResultRecorder
|
|
44
|
+
from commitguard.github.auth import AppCredentials, InstallationTokenProvider
|
|
45
|
+
from commitguard.github.checks import APP_CHECK_NAME, APP_PUSH_CHECK_NAME
|
|
46
|
+
from commitguard.github.client import API_URL, GitHubClient, Transport
|
|
47
|
+
from commitguard.github.errors import WebhookValidationError
|
|
48
|
+
from commitguard.github.events import (
|
|
49
|
+
CheckRunRerequestedEvent,
|
|
50
|
+
CheckSuiteRerequestedEvent,
|
|
51
|
+
GitHubWebhookEvent,
|
|
52
|
+
IgnoredEvent,
|
|
53
|
+
InstallationEvent,
|
|
54
|
+
InstallationRepositoriesEvent,
|
|
55
|
+
MergeGroupAction,
|
|
56
|
+
MergeGroupEvent,
|
|
57
|
+
PullRequestEvent,
|
|
58
|
+
PushEvent,
|
|
59
|
+
normalize_webhook,
|
|
60
|
+
)
|
|
61
|
+
from commitguard.github.identifiers import RepositoryRef
|
|
62
|
+
from commitguard.github.installations import InstallationService
|
|
63
|
+
from commitguard.github.pull_requests import (
|
|
64
|
+
PullRequestDisposition,
|
|
65
|
+
branch_group_key,
|
|
66
|
+
disposition,
|
|
67
|
+
group_key,
|
|
68
|
+
merge_group_key,
|
|
69
|
+
)
|
|
70
|
+
from commitguard.github.queue import DEFAULT_QUEUE_SIZE, EventQueue, InProcessEventQueue
|
|
71
|
+
from commitguard.github.recovery import RecoveryService
|
|
72
|
+
from commitguard.github.repositories import GitHubRemoteLocator, MirrorManager, RemoteLocator
|
|
73
|
+
from commitguard.github.settings import AppSettings, load_settings
|
|
74
|
+
from commitguard.github.storage import (
|
|
75
|
+
DATABASE_FILENAME,
|
|
76
|
+
DeliveryStatus,
|
|
77
|
+
EventProcessingStatus,
|
|
78
|
+
MergeGroupState,
|
|
79
|
+
NewScanJob,
|
|
80
|
+
ScanJob,
|
|
81
|
+
ScanTrigger,
|
|
82
|
+
SqliteStateStore,
|
|
83
|
+
)
|
|
84
|
+
from commitguard.github.webhooks import MAX_WEBHOOK_BYTES, WebhookDelivery, parse_delivery
|
|
85
|
+
from commitguard.github.worker import ScanWorker
|
|
86
|
+
from commitguard.governance.service import GovernanceServices
|
|
87
|
+
from commitguard.notifications.service import RUN_INTERVAL_SECONDS, NotificationService
|
|
88
|
+
from commitguard.notifications.settings import NotificationSettings
|
|
89
|
+
from commitguard.observability.logging import configure_json_logging, correlation, get_logger
|
|
90
|
+
from commitguard.observability.metrics import (
|
|
91
|
+
CHECK_RERUNS,
|
|
92
|
+
GITHUB_EVENTS_FAILED,
|
|
93
|
+
GITHUB_EVENTS_RECEIVED,
|
|
94
|
+
GITHUB_EVENTS_REPLAYED,
|
|
95
|
+
SCANS_QUEUED,
|
|
96
|
+
WEBHOOKS_DUPLICATE,
|
|
97
|
+
WEBHOOKS_RECEIVED,
|
|
98
|
+
WEBHOOKS_REJECTED,
|
|
99
|
+
InMemoryMetrics,
|
|
100
|
+
)
|
|
101
|
+
from commitguard.security.hashing import fingerprint
|
|
102
|
+
from commitguard.security.rate_limit import RequestRateLimiter
|
|
103
|
+
from commitguard.security.secrets import Secret
|
|
104
|
+
from commitguard.services.audit import AuditService
|
|
105
|
+
|
|
106
|
+
log = get_logger(__name__)
|
|
107
|
+
|
|
108
|
+
WEBHOOK_PATH = "/webhooks/github"
|
|
109
|
+
DEFAULT_RATE_LIMIT_PER_MINUTE = 600
|
|
110
|
+
RECOVERY_INTERVAL_SECONDS = 60.0
|
|
111
|
+
RECOVER_QUEUED_AFTER = timedelta(minutes=5)
|
|
112
|
+
RETENTION_INTERVAL_SECONDS = 3600.0
|
|
113
|
+
APP_CHECK_NAMES = frozenset({APP_CHECK_NAME, APP_PUSH_CHECK_NAME})
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@dataclass(frozen=True, slots=True)
|
|
117
|
+
class WebhookResult:
|
|
118
|
+
status: int
|
|
119
|
+
body: Mapping[str, str | int] = field(default_factory=dict)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class GitHubAppService:
|
|
123
|
+
def __init__(
|
|
124
|
+
self,
|
|
125
|
+
*,
|
|
126
|
+
credentials: AppCredentials,
|
|
127
|
+
webhook_secret: Secret,
|
|
128
|
+
store: SqliteStateStore,
|
|
129
|
+
client: GitHubClient,
|
|
130
|
+
mirrors: MirrorManager,
|
|
131
|
+
mandatory_policy: MandatoryPolicy | None = None,
|
|
132
|
+
workers: int = 2,
|
|
133
|
+
retention: timedelta = timedelta(days=30),
|
|
134
|
+
max_commits: int = 10_000,
|
|
135
|
+
queue: EventQueue | None = None,
|
|
136
|
+
metrics: InMemoryMetrics | None = None,
|
|
137
|
+
rate_limit_per_minute: int = DEFAULT_RATE_LIMIT_PER_MINUTE,
|
|
138
|
+
notification_settings: NotificationSettings | None = None,
|
|
139
|
+
notifications: NotificationService | None = None,
|
|
140
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
141
|
+
) -> None:
|
|
142
|
+
self.store = store
|
|
143
|
+
self.client = client
|
|
144
|
+
self.metrics = metrics or InMemoryMetrics()
|
|
145
|
+
self.queue = queue or InProcessEventQueue()
|
|
146
|
+
self.audit = AuditService([store], now=now)
|
|
147
|
+
self.tokens = InstallationTokenProvider(credentials, client)
|
|
148
|
+
self.mirrors = mirrors
|
|
149
|
+
self.installations = InstallationService(
|
|
150
|
+
store, self.tokens, client, mirrors, self.audit, now=now
|
|
151
|
+
)
|
|
152
|
+
self.policies = OrganizationPolicyService(
|
|
153
|
+
store, self.audit, service_policy=mandatory_policy, metrics=self.metrics, now=now
|
|
154
|
+
)
|
|
155
|
+
self.notifications = notifications or NotificationService(
|
|
156
|
+
store,
|
|
157
|
+
self.audit,
|
|
158
|
+
self.metrics,
|
|
159
|
+
notification_settings or NotificationSettings(),
|
|
160
|
+
now=now,
|
|
161
|
+
)
|
|
162
|
+
self.recorder = ScanResultRecorder(store, self.audit, now=now)
|
|
163
|
+
# Organization governance: groups, onboarding, exceptions, effective policy per repository.
|
|
164
|
+
self.governance = GovernanceServices(
|
|
165
|
+
store,
|
|
166
|
+
self.audit,
|
|
167
|
+
self.policies,
|
|
168
|
+
installations=self.installations,
|
|
169
|
+
client=client,
|
|
170
|
+
enqueue=self.queue.put,
|
|
171
|
+
now=now,
|
|
172
|
+
)
|
|
173
|
+
self.installations.add_discovery_listener(self._repositories_discovered)
|
|
174
|
+
self.worker = ScanWorker(
|
|
175
|
+
store=store,
|
|
176
|
+
installations=self.installations,
|
|
177
|
+
client=client,
|
|
178
|
+
mirrors=mirrors,
|
|
179
|
+
audit=self.audit,
|
|
180
|
+
metrics=self.metrics,
|
|
181
|
+
policy_resolver=self.policies.mandatory_for_installation,
|
|
182
|
+
governance_resolver=self.governance.scan_governance,
|
|
183
|
+
recorder=self.recorder,
|
|
184
|
+
max_commits=max_commits,
|
|
185
|
+
now=now,
|
|
186
|
+
)
|
|
187
|
+
self.recovery = RecoveryService(
|
|
188
|
+
store, self.audit, self.metrics, enqueue=self.queue.put, now=now
|
|
189
|
+
)
|
|
190
|
+
self._credentials = credentials
|
|
191
|
+
self._secret = webhook_secret
|
|
192
|
+
self._workers = workers
|
|
193
|
+
self._retention = retention
|
|
194
|
+
self._now = now
|
|
195
|
+
self._limiter = RequestRateLimiter(rate_limit_per_minute)
|
|
196
|
+
self._stop = threading.Event()
|
|
197
|
+
self._threads: list[threading.Thread] = []
|
|
198
|
+
self._maintenance_tasks: list[Callable[[], object]] = []
|
|
199
|
+
|
|
200
|
+
def _repositories_discovered(
|
|
201
|
+
self, account_id: int, installation_id: int, repositories: Sequence[RepositoryRef]
|
|
202
|
+
) -> None:
|
|
203
|
+
self.governance.inventory.discovered(account_id, installation_id, repositories)
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------ #
|
|
206
|
+
# Construction
|
|
207
|
+
# ------------------------------------------------------------------ #
|
|
208
|
+
@classmethod
|
|
209
|
+
def create(
|
|
210
|
+
cls,
|
|
211
|
+
*,
|
|
212
|
+
app_id: int,
|
|
213
|
+
private_key: Secret,
|
|
214
|
+
webhook_secret: Secret,
|
|
215
|
+
data_dir: Path,
|
|
216
|
+
mandatory_policy_file: Path | None = None,
|
|
217
|
+
transport: Transport | None = None,
|
|
218
|
+
api_url: str = API_URL,
|
|
219
|
+
remote_locator: RemoteLocator | None = None,
|
|
220
|
+
allowed_git_protocols: tuple[str, ...] = ("https",),
|
|
221
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
222
|
+
**options: Any,
|
|
223
|
+
) -> "GitHubAppService":
|
|
224
|
+
credentials = AppCredentials(app_id, private_key) # fails closed on a bad key
|
|
225
|
+
mandatory = load_mandatory_policy(mandatory_policy_file) if mandatory_policy_file else None
|
|
226
|
+
data_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
227
|
+
metrics = options.pop("metrics", None) or InMemoryMetrics()
|
|
228
|
+
client = GitHubClient(transport, api_url=api_url, sleep=sleep, metrics=metrics)
|
|
229
|
+
mirrors = MirrorManager(
|
|
230
|
+
data_dir / "mirrors",
|
|
231
|
+
remote_locator or GitHubRemoteLocator(),
|
|
232
|
+
allowed_protocols=allowed_git_protocols,
|
|
233
|
+
)
|
|
234
|
+
return cls(
|
|
235
|
+
credentials=credentials,
|
|
236
|
+
webhook_secret=webhook_secret,
|
|
237
|
+
store=SqliteStateStore(data_dir / DATABASE_FILENAME),
|
|
238
|
+
client=client,
|
|
239
|
+
mirrors=mirrors,
|
|
240
|
+
mandatory_policy=mandatory,
|
|
241
|
+
metrics=metrics,
|
|
242
|
+
**options,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
@classmethod
|
|
246
|
+
def from_settings(cls, settings: AppSettings, **overrides: Any) -> "GitHubAppService":
|
|
247
|
+
return cls.create(
|
|
248
|
+
app_id=settings.app_id,
|
|
249
|
+
private_key=settings.private_key,
|
|
250
|
+
webhook_secret=settings.webhook_secret,
|
|
251
|
+
data_dir=settings.data_dir,
|
|
252
|
+
mandatory_policy_file=settings.mandatory_policy_file,
|
|
253
|
+
workers=settings.workers,
|
|
254
|
+
retention=settings.retention,
|
|
255
|
+
max_commits=settings.max_commits,
|
|
256
|
+
**overrides,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
# ------------------------------------------------------------------ #
|
|
260
|
+
# Webhooks
|
|
261
|
+
# ------------------------------------------------------------------ #
|
|
262
|
+
def handle_webhook(
|
|
263
|
+
self, headers: Mapping[str, str], body: bytes, *, remote_addr: str | None = None
|
|
264
|
+
) -> WebhookResult:
|
|
265
|
+
self.metrics.increment(WEBHOOKS_RECEIVED)
|
|
266
|
+
if not self._limiter.allow(remote_addr or "unknown"):
|
|
267
|
+
self.metrics.increment(WEBHOOKS_REJECTED, reason="rate_limited")
|
|
268
|
+
return WebhookResult(429, {"error": "too many requests"})
|
|
269
|
+
try:
|
|
270
|
+
delivery = parse_delivery(headers, body, self._secret)
|
|
271
|
+
except WebhookValidationError as exc:
|
|
272
|
+
self.metrics.increment(WEBHOOKS_REJECTED, reason=str(exc.status))
|
|
273
|
+
log.warning("webhook_rejected", status=exc.status, reason=str(exc))
|
|
274
|
+
return WebhookResult(exc.status, {"error": str(exc)})
|
|
275
|
+
with correlation(delivery_id=delivery.delivery_id):
|
|
276
|
+
return self._handle_verified(delivery)
|
|
277
|
+
|
|
278
|
+
def _handle_verified(self, delivery: WebhookDelivery) -> WebhookResult:
|
|
279
|
+
self.metrics.increment(GITHUB_EVENTS_RECEIVED, event=delivery.event)
|
|
280
|
+
try:
|
|
281
|
+
event = normalize_webhook(delivery.event, delivery.payload)
|
|
282
|
+
except WebhookValidationError as exc:
|
|
283
|
+
self.metrics.increment(WEBHOOKS_REJECTED, reason="invalid_payload")
|
|
284
|
+
log.warning("webhook_invalid", event_name=delivery.event, reason=str(exc))
|
|
285
|
+
self.audit.record(
|
|
286
|
+
AuditEventType.WEBHOOK_REJECTED, event_name=delivery.event, reason=str(exc)
|
|
287
|
+
)
|
|
288
|
+
return WebhookResult(exc.status, {"error": str(exc)})
|
|
289
|
+
|
|
290
|
+
action = delivery.payload.get("action")
|
|
291
|
+
status = self.store.record_delivery(
|
|
292
|
+
delivery.delivery_id,
|
|
293
|
+
delivery.event,
|
|
294
|
+
delivery.body_sha256,
|
|
295
|
+
self._now(),
|
|
296
|
+
action=action if isinstance(action, str) and len(action) <= 64 else None,
|
|
297
|
+
)
|
|
298
|
+
if status is DeliveryStatus.DUPLICATE:
|
|
299
|
+
self.metrics.increment(WEBHOOKS_DUPLICATE)
|
|
300
|
+
self.metrics.increment(GITHUB_EVENTS_REPLAYED, event=delivery.event)
|
|
301
|
+
log.info("webhook_duplicate", event_name=delivery.event)
|
|
302
|
+
return WebhookResult(200, {"status": "duplicate"})
|
|
303
|
+
if status is DeliveryStatus.RETRY:
|
|
304
|
+
log.info("webhook_redelivery_processed_again", event_name=delivery.event)
|
|
305
|
+
if status is DeliveryStatus.CONFLICT:
|
|
306
|
+
self.metrics.increment(WEBHOOKS_REJECTED, reason="delivery_conflict")
|
|
307
|
+
log.warning("webhook_delivery_id_conflict", event_name=delivery.event)
|
|
308
|
+
self.audit.record(
|
|
309
|
+
AuditEventType.WEBHOOK_REJECTED,
|
|
310
|
+
event_name=delivery.event,
|
|
311
|
+
reason="delivery ID reused with a different payload",
|
|
312
|
+
)
|
|
313
|
+
return WebhookResult(409, {"error": "delivery already received with different content"})
|
|
314
|
+
|
|
315
|
+
log.info("webhook_accepted", event_name=delivery.event, kind=event.kind)
|
|
316
|
+
try:
|
|
317
|
+
result = self._dispatch(event, delivery)
|
|
318
|
+
except Exception as exc:
|
|
319
|
+
# The event record stays "failed": GitHub's redelivery of this delivery ID is
|
|
320
|
+
# processed again instead of being dropped as a duplicate.
|
|
321
|
+
self.metrics.increment(GITHUB_EVENTS_FAILED, event=delivery.event)
|
|
322
|
+
self.store.finish_delivery(
|
|
323
|
+
delivery.delivery_id,
|
|
324
|
+
EventProcessingStatus.FAILED,
|
|
325
|
+
self._now(),
|
|
326
|
+
detail=f"processing failed ({type(exc).__name__})",
|
|
327
|
+
)
|
|
328
|
+
raise
|
|
329
|
+
ignored = result.body.get("status") in ("ignored", "duplicate")
|
|
330
|
+
self.store.finish_delivery(
|
|
331
|
+
delivery.delivery_id,
|
|
332
|
+
EventProcessingStatus.IGNORED if ignored else EventProcessingStatus.PROCESSED,
|
|
333
|
+
self._now(),
|
|
334
|
+
installation_id=getattr(event, "installation_id", None),
|
|
335
|
+
repository_id=getattr(getattr(event, "repository", None), "id", None),
|
|
336
|
+
detail=str(result.body.get("status", "")),
|
|
337
|
+
)
|
|
338
|
+
return result
|
|
339
|
+
|
|
340
|
+
def _dispatch(self, event: GitHubWebhookEvent, delivery: WebhookDelivery) -> WebhookResult:
|
|
341
|
+
if isinstance(event, IgnoredEvent):
|
|
342
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
343
|
+
if isinstance(event, InstallationEvent):
|
|
344
|
+
self.installations.handle_installation(event)
|
|
345
|
+
return WebhookResult(200, {"status": "processed"})
|
|
346
|
+
if isinstance(event, InstallationRepositoriesEvent):
|
|
347
|
+
self.installations.handle_repositories(event)
|
|
348
|
+
return WebhookResult(200, {"status": "processed"})
|
|
349
|
+
|
|
350
|
+
with correlation(
|
|
351
|
+
installation_id=event.installation_id, repository=event.repository.full_name
|
|
352
|
+
):
|
|
353
|
+
denial = self.installations.denial_reason(event.installation_id)
|
|
354
|
+
if denial is not None:
|
|
355
|
+
self.audit.record(
|
|
356
|
+
AuditEventType.AUTHORIZATION_DENIED,
|
|
357
|
+
installation_id=event.installation_id,
|
|
358
|
+
repository_id=event.repository.id,
|
|
359
|
+
repository=event.repository.full_name,
|
|
360
|
+
reason=denial,
|
|
361
|
+
)
|
|
362
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
363
|
+
if isinstance(event, PushEvent):
|
|
364
|
+
return self._push(event, delivery)
|
|
365
|
+
if isinstance(event, MergeGroupEvent):
|
|
366
|
+
return self._merge_group(event, delivery)
|
|
367
|
+
if isinstance(event, CheckRunRerequestedEvent):
|
|
368
|
+
return self._check_run_rerequested(event, delivery)
|
|
369
|
+
if isinstance(event, CheckSuiteRerequestedEvent):
|
|
370
|
+
return self._check_suite_rerequested(event, delivery)
|
|
371
|
+
return self._pull_request(event, delivery)
|
|
372
|
+
|
|
373
|
+
def _monitoring_paused(self, installation_id: int, repository_id: int) -> bool:
|
|
374
|
+
if self.store.monitoring_enabled(installation_id, repository_id):
|
|
375
|
+
return False
|
|
376
|
+
log.info("repository_monitoring_paused")
|
|
377
|
+
return True
|
|
378
|
+
|
|
379
|
+
def _push(self, event: PushEvent, delivery: WebhookDelivery) -> WebhookResult:
|
|
380
|
+
context = event.context
|
|
381
|
+
ref = context.ref or ""
|
|
382
|
+
if not ref.startswith("refs/heads/"):
|
|
383
|
+
return WebhookResult(202, {"status": "ignored"}) # tags are not scanned
|
|
384
|
+
if context.ref_deleted or context.after_sha is None:
|
|
385
|
+
# Nothing to scan; violations seen only on this branch are no longer present.
|
|
386
|
+
self.recorder.branch_deleted(event.installation_id, event.repository.id, ref)
|
|
387
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
388
|
+
if self._monitoring_paused(event.installation_id, event.repository.id):
|
|
389
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
390
|
+
return self._enqueue(
|
|
391
|
+
NewScanJob(
|
|
392
|
+
job_key=fingerprint(["push", ref, context.before_sha or "", context.after_sha]),
|
|
393
|
+
installation_id=event.installation_id,
|
|
394
|
+
repository=event.repository,
|
|
395
|
+
delivery_id=delivery.delivery_id,
|
|
396
|
+
event="push",
|
|
397
|
+
group_key=branch_group_key(ref),
|
|
398
|
+
head_sha=context.after_sha,
|
|
399
|
+
check_name=APP_PUSH_CHECK_NAME,
|
|
400
|
+
pull_request_number=None,
|
|
401
|
+
context=context,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
def _pull_request(self, event: PullRequestEvent, delivery: WebhookDelivery) -> WebhookResult:
|
|
406
|
+
action = disposition(event)
|
|
407
|
+
if action is PullRequestDisposition.IGNORE:
|
|
408
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
409
|
+
if action is PullRequestDisposition.CLOSE:
|
|
410
|
+
self.store.cancel_queued_group(
|
|
411
|
+
event.installation_id, event.repository.id, group_key(event.number), self._now()
|
|
412
|
+
)
|
|
413
|
+
self.recorder.pull_request_closed(
|
|
414
|
+
event.installation_id,
|
|
415
|
+
event.repository.id,
|
|
416
|
+
event.number,
|
|
417
|
+
merged=False,
|
|
418
|
+
base_ref=None,
|
|
419
|
+
)
|
|
420
|
+
return WebhookResult(200, {"status": "processed"})
|
|
421
|
+
if action is PullRequestDisposition.RECORD_MERGE:
|
|
422
|
+
self.recorder.pull_request_closed(
|
|
423
|
+
event.installation_id,
|
|
424
|
+
event.repository.id,
|
|
425
|
+
event.number,
|
|
426
|
+
merged=True,
|
|
427
|
+
base_ref=event.context.ref,
|
|
428
|
+
)
|
|
429
|
+
self.audit.record(
|
|
430
|
+
AuditEventType.PULL_REQUEST_MERGED,
|
|
431
|
+
installation_id=event.installation_id,
|
|
432
|
+
repository_id=event.repository.id,
|
|
433
|
+
repository=event.repository.full_name,
|
|
434
|
+
head_sha=event.context.head_sha,
|
|
435
|
+
pull_request=event.number,
|
|
436
|
+
)
|
|
437
|
+
return WebhookResult(200, {"status": "processed"})
|
|
438
|
+
if self._monitoring_paused(event.installation_id, event.repository.id):
|
|
439
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
440
|
+
context = event.context
|
|
441
|
+
head = context.head_sha or ""
|
|
442
|
+
result = self._enqueue(
|
|
443
|
+
NewScanJob(
|
|
444
|
+
job_key=fingerprint(
|
|
445
|
+
["pull_request", str(event.number), context.base_sha or "", head]
|
|
446
|
+
),
|
|
447
|
+
installation_id=event.installation_id,
|
|
448
|
+
repository=event.repository,
|
|
449
|
+
delivery_id=delivery.delivery_id,
|
|
450
|
+
event="pull_request",
|
|
451
|
+
group_key=group_key(event.number),
|
|
452
|
+
head_sha=head,
|
|
453
|
+
check_name=APP_CHECK_NAME,
|
|
454
|
+
pull_request_number=event.number,
|
|
455
|
+
context=context,
|
|
456
|
+
)
|
|
457
|
+
)
|
|
458
|
+
if event.action == "reopened" and result.body.get("status") == "duplicate":
|
|
459
|
+
# Same commits as an earlier completed scan: nothing is re-scanned, so the
|
|
460
|
+
# violations that closing the pull request ended are present again.
|
|
461
|
+
self.recorder.pull_request_reopened(
|
|
462
|
+
event.installation_id, event.repository.id, event.number
|
|
463
|
+
)
|
|
464
|
+
return result
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------------------------ #
|
|
467
|
+
# Merge queue
|
|
468
|
+
# ------------------------------------------------------------------ #
|
|
469
|
+
def _merge_group(self, event: MergeGroupEvent, delivery: WebhookDelivery) -> WebhookResult:
|
|
470
|
+
"""Validate the exact merge group commit the merge queue waits on.
|
|
471
|
+
|
|
472
|
+
The pull request's own check is not reused: a merge group combines the pull
|
|
473
|
+
request with the latest base branch and the changes queued ahead of it, so it
|
|
474
|
+
is scanned as ``base_sha..head_sha`` and the result is published to
|
|
475
|
+
``head_sha``. Every merge group SHA has its own scan; a recreated group is a
|
|
476
|
+
new SHA and gets a new scan.
|
|
477
|
+
"""
|
|
478
|
+
common: dict[str, Any] = {
|
|
479
|
+
"installation_id": event.installation_id,
|
|
480
|
+
"repository_id": event.repository.id,
|
|
481
|
+
"head_sha": event.head_sha,
|
|
482
|
+
"head_ref": event.head_ref,
|
|
483
|
+
"base_sha": event.base_sha,
|
|
484
|
+
"base_ref": event.base_ref,
|
|
485
|
+
"pull_requests": event.pull_requests,
|
|
486
|
+
}
|
|
487
|
+
prs = ",".join(f"#{n}" for n in event.pull_requests) or None
|
|
488
|
+
if event.action is MergeGroupAction.DESTROYED:
|
|
489
|
+
record, changed = self.store.destroy_merge_group(
|
|
490
|
+
**common, reason=event.reason, now=self._now()
|
|
491
|
+
)
|
|
492
|
+
if not changed:
|
|
493
|
+
return WebhookResult(202, {"status": "duplicate"})
|
|
494
|
+
self.store.cancel_queued_group(
|
|
495
|
+
event.installation_id,
|
|
496
|
+
event.repository.id,
|
|
497
|
+
merge_group_key(event.head_sha),
|
|
498
|
+
self._now(),
|
|
499
|
+
)
|
|
500
|
+
self.recorder.merge_group_destroyed(
|
|
501
|
+
event.installation_id,
|
|
502
|
+
event.repository.id,
|
|
503
|
+
event.head_sha,
|
|
504
|
+
reason=event.reason,
|
|
505
|
+
base_ref=event.base_ref,
|
|
506
|
+
)
|
|
507
|
+
self.audit.record(
|
|
508
|
+
AuditEventType.MERGE_GROUP_DESTROYED,
|
|
509
|
+
installation_id=event.installation_id,
|
|
510
|
+
repository_id=event.repository.id,
|
|
511
|
+
repository=event.repository.full_name,
|
|
512
|
+
head_sha=event.head_sha,
|
|
513
|
+
reason=event.reason,
|
|
514
|
+
pull_requests=prs,
|
|
515
|
+
job=record.job_id,
|
|
516
|
+
)
|
|
517
|
+
return WebhookResult(200, {"status": "processed"})
|
|
518
|
+
|
|
519
|
+
if self._monitoring_paused(event.installation_id, event.repository.id):
|
|
520
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
521
|
+
record, requested = self.store.record_merge_group(
|
|
522
|
+
**common, delivery_id=delivery.delivery_id, now=self._now()
|
|
523
|
+
)
|
|
524
|
+
if not requested:
|
|
525
|
+
reason = (
|
|
526
|
+
"merge group already destroyed"
|
|
527
|
+
if record.state is MergeGroupState.DESTROYED
|
|
528
|
+
else "merge group already requested"
|
|
529
|
+
)
|
|
530
|
+
log.info("merge_group_not_scanned", reason=reason)
|
|
531
|
+
return WebhookResult(202, {"status": "duplicate"})
|
|
532
|
+
self.audit.record(
|
|
533
|
+
AuditEventType.MERGE_GROUP_CREATED,
|
|
534
|
+
installation_id=event.installation_id,
|
|
535
|
+
repository_id=event.repository.id,
|
|
536
|
+
repository=event.repository.full_name,
|
|
537
|
+
head_sha=event.head_sha,
|
|
538
|
+
base_sha=event.base_sha,
|
|
539
|
+
base_ref=event.base_ref,
|
|
540
|
+
pull_requests=prs,
|
|
541
|
+
)
|
|
542
|
+
result = self._enqueue(
|
|
543
|
+
NewScanJob(
|
|
544
|
+
job_key=fingerprint(
|
|
545
|
+
["merge_group", event.head_ref, event.base_sha, event.head_sha]
|
|
546
|
+
),
|
|
547
|
+
installation_id=event.installation_id,
|
|
548
|
+
repository=event.repository,
|
|
549
|
+
delivery_id=delivery.delivery_id,
|
|
550
|
+
event="merge_group",
|
|
551
|
+
group_key=merge_group_key(event.head_sha),
|
|
552
|
+
head_sha=event.head_sha,
|
|
553
|
+
check_name=APP_CHECK_NAME, # the required check, on the merge group commit
|
|
554
|
+
pull_request_number=None, # queued pull requests are listed on the merge group
|
|
555
|
+
context=event.context,
|
|
556
|
+
)
|
|
557
|
+
)
|
|
558
|
+
job = self.store.latest_group_job(
|
|
559
|
+
event.installation_id, event.repository.id, merge_group_key(event.head_sha)
|
|
560
|
+
)
|
|
561
|
+
if job is not None:
|
|
562
|
+
self.store.set_merge_group_job(
|
|
563
|
+
event.installation_id, event.repository.id, event.head_sha, job.job_id, self._now()
|
|
564
|
+
)
|
|
565
|
+
return result
|
|
566
|
+
|
|
567
|
+
# ------------------------------------------------------------------ #
|
|
568
|
+
# Check re-runs
|
|
569
|
+
# ------------------------------------------------------------------ #
|
|
570
|
+
def _reject_rerun(
|
|
571
|
+
self, event: CheckRunRerequestedEvent | CheckSuiteRerequestedEvent, reason: str
|
|
572
|
+
) -> WebhookResult:
|
|
573
|
+
self.audit.record(
|
|
574
|
+
AuditEventType.CHECK_RERUN_REJECTED,
|
|
575
|
+
installation_id=event.installation_id,
|
|
576
|
+
repository_id=event.repository.id,
|
|
577
|
+
repository=event.repository.full_name,
|
|
578
|
+
head_sha=event.head_sha,
|
|
579
|
+
reason=reason,
|
|
580
|
+
)
|
|
581
|
+
log.info("check_rerun_rejected", reason=reason)
|
|
582
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
583
|
+
|
|
584
|
+
def _check_run_rerequested(
|
|
585
|
+
self, event: CheckRunRerequestedEvent, delivery: WebhookDelivery
|
|
586
|
+
) -> WebhookResult:
|
|
587
|
+
"""GitHub "Re-run" on a CommitGuard check run: a new execution of the same scan.
|
|
588
|
+
|
|
589
|
+
The stored scan is found through the check run's ``external_id`` (the job ID
|
|
590
|
+
CommitGuard set when it created the run) and must match the event's
|
|
591
|
+
installation, repository, commit and check name exactly; nothing else in the
|
|
592
|
+
payload is used.
|
|
593
|
+
"""
|
|
594
|
+
if event.app_id != self._credentials.app_id:
|
|
595
|
+
return WebhookResult(202, {"status": "ignored"}) # another App's check
|
|
596
|
+
job = self.store.get_job(event.external_id) if event.external_id else None
|
|
597
|
+
if (
|
|
598
|
+
job is None
|
|
599
|
+
or job.installation_id != event.installation_id
|
|
600
|
+
or job.repository.id != event.repository.id
|
|
601
|
+
or job.head_sha != event.head_sha
|
|
602
|
+
or job.check_name != event.name
|
|
603
|
+
):
|
|
604
|
+
return self._reject_rerun(event, "the check run does not match a CommitGuard scan")
|
|
605
|
+
return self._request_rerun(event, job, delivery)
|
|
606
|
+
|
|
607
|
+
def _check_suite_rerequested(
|
|
608
|
+
self, event: CheckSuiteRerequestedEvent, delivery: WebhookDelivery
|
|
609
|
+
) -> WebhookResult:
|
|
610
|
+
"""GitHub "Re-run all checks": re-run CommitGuard's newest scan per check on the commit."""
|
|
611
|
+
if event.app_id != self._credentials.app_id:
|
|
612
|
+
return WebhookResult(202, {"status": "ignored"})
|
|
613
|
+
jobs = [
|
|
614
|
+
j
|
|
615
|
+
for j in self.store.latest_jobs_for_commit(
|
|
616
|
+
event.installation_id, event.repository.id, event.head_sha
|
|
617
|
+
)
|
|
618
|
+
if j.check_name in APP_CHECK_NAMES
|
|
619
|
+
]
|
|
620
|
+
if not jobs:
|
|
621
|
+
return self._reject_rerun(event, "no CommitGuard scan exists for this commit")
|
|
622
|
+
results = [self._request_rerun(event, job, delivery) for job in jobs]
|
|
623
|
+
statuses = {r.body.get("status") for r in results}
|
|
624
|
+
status = "queued" if "queued" in statuses else sorted(str(x) for x in statuses)[0]
|
|
625
|
+
return WebhookResult(202, {"status": status})
|
|
626
|
+
|
|
627
|
+
def _request_rerun(
|
|
628
|
+
self,
|
|
629
|
+
event: CheckRunRerequestedEvent | CheckSuiteRerequestedEvent,
|
|
630
|
+
job: ScanJob,
|
|
631
|
+
delivery: WebhookDelivery,
|
|
632
|
+
) -> WebhookResult:
|
|
633
|
+
if self._monitoring_paused(job.installation_id, job.repository.id):
|
|
634
|
+
return self._reject_rerun(event, "monitoring is paused for this repository")
|
|
635
|
+
latest = self.store.latest_group_job(job.installation_id, job.repository.id, job.group_key)
|
|
636
|
+
if latest is not None and latest.scan_key != job.scan_key:
|
|
637
|
+
# Re-running an outdated commit's check would record old commits as the
|
|
638
|
+
# current state of the pull request or branch.
|
|
639
|
+
return self._reject_rerun(
|
|
640
|
+
event, "a newer commit has been scanned for this pull request or branch"
|
|
641
|
+
)
|
|
642
|
+
if job.event == "merge_group":
|
|
643
|
+
group = self.store.get_merge_group(job.installation_id, job.repository.id, job.head_sha)
|
|
644
|
+
if group is None or group.state is MergeGroupState.DESTROYED:
|
|
645
|
+
return self._reject_rerun(event, "the merge group no longer exists")
|
|
646
|
+
execution, created = self.store.create_execution(
|
|
647
|
+
job, trigger=ScanTrigger.RERUN, now=self._now(), delivery_id=delivery.delivery_id
|
|
648
|
+
)
|
|
649
|
+
if not created:
|
|
650
|
+
self.metrics.increment(WEBHOOKS_DUPLICATE, reason="rerun")
|
|
651
|
+
return WebhookResult(202, {"status": "duplicate"})
|
|
652
|
+
self.metrics.increment(CHECK_RERUNS)
|
|
653
|
+
self.audit.record(
|
|
654
|
+
AuditEventType.CHECK_RERUN_REQUESTED,
|
|
655
|
+
actor=GITHUB_ACTOR,
|
|
656
|
+
installation_id=job.installation_id,
|
|
657
|
+
repository_id=job.repository.id,
|
|
658
|
+
repository=job.repository.full_name,
|
|
659
|
+
head_sha=job.head_sha,
|
|
660
|
+
job=execution.job_id,
|
|
661
|
+
previous_scan=job.job_id,
|
|
662
|
+
execution=execution.execution,
|
|
663
|
+
check=job.check_name,
|
|
664
|
+
)
|
|
665
|
+
self.queue.put(execution.job_id)
|
|
666
|
+
return WebhookResult(202, {"status": "queued"})
|
|
667
|
+
|
|
668
|
+
def _enqueue(self, new_job: NewScanJob) -> WebhookResult:
|
|
669
|
+
job, created = self.store.create_job(new_job, self._now())
|
|
670
|
+
if not created:
|
|
671
|
+
self.metrics.increment(WEBHOOKS_DUPLICATE, reason="scan")
|
|
672
|
+
log.info("scan_already_known", job_id=job.job_id, state=job.state.value)
|
|
673
|
+
return WebhookResult(202, {"status": "duplicate"})
|
|
674
|
+
self.metrics.increment(SCANS_QUEUED)
|
|
675
|
+
self.audit.record(
|
|
676
|
+
AuditEventType.SCAN_QUEUED,
|
|
677
|
+
installation_id=job.installation_id,
|
|
678
|
+
repository_id=job.repository.id,
|
|
679
|
+
repository=job.repository.full_name,
|
|
680
|
+
head_sha=job.head_sha,
|
|
681
|
+
job=job.job_id,
|
|
682
|
+
)
|
|
683
|
+
self.queue.put(job.job_id) # if full, recovery picks the stored job up later
|
|
684
|
+
return WebhookResult(202, {"status": "queued"})
|
|
685
|
+
|
|
686
|
+
# ------------------------------------------------------------------ #
|
|
687
|
+
# Workers and maintenance
|
|
688
|
+
# ------------------------------------------------------------------ #
|
|
689
|
+
def process_pending(self, max_jobs: int | None = None) -> int:
|
|
690
|
+
"""Process queued jobs synchronously on the calling thread (tests, one-off runs)."""
|
|
691
|
+
processed = 0
|
|
692
|
+
while max_jobs is None or processed < max_jobs:
|
|
693
|
+
job_id = self.queue.get(timeout=0)
|
|
694
|
+
if job_id is None:
|
|
695
|
+
break
|
|
696
|
+
if self.worker.process(job_id) is not None:
|
|
697
|
+
processed += 1
|
|
698
|
+
return processed
|
|
699
|
+
|
|
700
|
+
def recover(self, *, queued_before: datetime | None = None) -> int:
|
|
701
|
+
"""Re-enqueue stored jobs that were queued long ago or abandoned by a crash."""
|
|
702
|
+
now = self._now()
|
|
703
|
+
ids = self.store.recoverable_jobs(now, queued_before=queued_before or now)
|
|
704
|
+
for job_id in ids:
|
|
705
|
+
self.queue.put(job_id)
|
|
706
|
+
return len(ids)
|
|
707
|
+
|
|
708
|
+
def add_maintenance_task(self, task: Callable[[], object]) -> None:
|
|
709
|
+
"""Run ``task`` with the hourly retention purge (e.g. expired dashboard sessions)."""
|
|
710
|
+
self._maintenance_tasks.append(task)
|
|
711
|
+
|
|
712
|
+
def purge_expired(self) -> dict[str, int]:
|
|
713
|
+
cutoff = self._now() - self._retention
|
|
714
|
+
counts = self.store.purge_expired(cutoff)
|
|
715
|
+
counts["mirrors"] = self.mirrors.purge_unused(self._retention.total_seconds())
|
|
716
|
+
counts["notifications"] = self.notifications.purge_expired()
|
|
717
|
+
for task in self._maintenance_tasks:
|
|
718
|
+
task()
|
|
719
|
+
log.info("retention_purge", **counts)
|
|
720
|
+
return counts
|
|
721
|
+
|
|
722
|
+
def _worker_loop(self) -> None:
|
|
723
|
+
while not self._stop.is_set():
|
|
724
|
+
job_id = self.queue.get(timeout=1.0)
|
|
725
|
+
if job_id is None:
|
|
726
|
+
continue
|
|
727
|
+
try:
|
|
728
|
+
self.worker.process(job_id)
|
|
729
|
+
except Exception as exc: # noqa: BLE001 - keep the worker alive
|
|
730
|
+
log.error("worker_crashed_on_job", error_type=type(exc).__name__)
|
|
731
|
+
|
|
732
|
+
def _notification_loop(self) -> None:
|
|
733
|
+
while not self._stop.wait(RUN_INTERVAL_SECONDS):
|
|
734
|
+
try:
|
|
735
|
+
self.notifications.run_once()
|
|
736
|
+
except Exception as exc: # noqa: BLE001 - keep notifications alive
|
|
737
|
+
log.error("notifications_failed", error_type=type(exc).__name__)
|
|
738
|
+
|
|
739
|
+
def _maintenance_loop(self) -> None:
|
|
740
|
+
last_purge = 0.0
|
|
741
|
+
while not self._stop.wait(RECOVERY_INTERVAL_SECONDS):
|
|
742
|
+
try:
|
|
743
|
+
self.recover(queued_before=self._now() - RECOVER_QUEUED_AFTER)
|
|
744
|
+
self.recovery.run_once()
|
|
745
|
+
self.governance.run_maintenance()
|
|
746
|
+
if time.monotonic() - last_purge > RETENTION_INTERVAL_SECONDS:
|
|
747
|
+
self.purge_expired()
|
|
748
|
+
last_purge = time.monotonic()
|
|
749
|
+
except Exception as exc: # noqa: BLE001 - keep maintenance alive
|
|
750
|
+
log.error("maintenance_failed", error_type=type(exc).__name__)
|
|
751
|
+
|
|
752
|
+
def start(self) -> None:
|
|
753
|
+
if self._threads:
|
|
754
|
+
return
|
|
755
|
+
self._stop.clear()
|
|
756
|
+
self.recover()
|
|
757
|
+
for index in range(self._workers):
|
|
758
|
+
thread = threading.Thread(
|
|
759
|
+
target=self._worker_loop, name=f"commitguard-worker-{index}", daemon=True
|
|
760
|
+
)
|
|
761
|
+
thread.start()
|
|
762
|
+
self._threads.append(thread)
|
|
763
|
+
maintenance = threading.Thread(
|
|
764
|
+
target=self._maintenance_loop, name="commitguard-maintenance", daemon=True
|
|
765
|
+
)
|
|
766
|
+
maintenance.start()
|
|
767
|
+
self._threads.append(maintenance)
|
|
768
|
+
notifications = threading.Thread(
|
|
769
|
+
target=self._notification_loop, name="commitguard-notifications", daemon=True
|
|
770
|
+
)
|
|
771
|
+
notifications.start()
|
|
772
|
+
self._threads.append(notifications)
|
|
773
|
+
log.info("service_started", workers=self._workers)
|
|
774
|
+
|
|
775
|
+
def stop(self, timeout: float = 10.0) -> None:
|
|
776
|
+
self._stop.set()
|
|
777
|
+
for thread in self._threads:
|
|
778
|
+
thread.join(timeout)
|
|
779
|
+
self._threads.clear()
|
|
780
|
+
|
|
781
|
+
# ------------------------------------------------------------------ #
|
|
782
|
+
# Health
|
|
783
|
+
# ------------------------------------------------------------------ #
|
|
784
|
+
def readiness(self) -> tuple[bool, dict[str, str]]:
|
|
785
|
+
checks = {
|
|
786
|
+
"configuration": "ok", # credentials were parsed at construction
|
|
787
|
+
"store": "ok" if self.store.ping() else "unavailable",
|
|
788
|
+
"workers": "ok"
|
|
789
|
+
if self._threads and all(t.is_alive() for t in self._threads)
|
|
790
|
+
else "not running",
|
|
791
|
+
"queue": "ok" if self.queue.size() < DEFAULT_QUEUE_SIZE * 0.9 else "saturated",
|
|
792
|
+
}
|
|
793
|
+
return all(v == "ok" for v in checks.values()), checks
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# --------------------------------------------------------------------------- #
|
|
797
|
+
# WSGI
|
|
798
|
+
# --------------------------------------------------------------------------- #
|
|
799
|
+
_SECURITY_HEADERS = [
|
|
800
|
+
("Cache-Control", "no-store"),
|
|
801
|
+
("X-Content-Type-Options", "nosniff"),
|
|
802
|
+
("Content-Security-Policy", "default-src 'none'"),
|
|
803
|
+
]
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _json_response(
|
|
807
|
+
start_response: StartResponse,
|
|
808
|
+
status: int,
|
|
809
|
+
body: Mapping[str, object],
|
|
810
|
+
extra_headers: Iterable[tuple[str, str]] = (),
|
|
811
|
+
) -> list[bytes]:
|
|
812
|
+
payload = json.dumps(body, ensure_ascii=True, sort_keys=True).encode("ascii")
|
|
813
|
+
reason = {
|
|
814
|
+
200: "OK",
|
|
815
|
+
202: "Accepted",
|
|
816
|
+
400: "Bad Request",
|
|
817
|
+
401: "Unauthorized",
|
|
818
|
+
404: "Not Found",
|
|
819
|
+
405: "Method Not Allowed",
|
|
820
|
+
409: "Conflict",
|
|
821
|
+
411: "Length Required",
|
|
822
|
+
413: "Payload Too Large",
|
|
823
|
+
415: "Unsupported Media Type",
|
|
824
|
+
429: "Too Many Requests",
|
|
825
|
+
500: "Internal Server Error",
|
|
826
|
+
503: "Service Unavailable",
|
|
827
|
+
}.get(status, "Error")
|
|
828
|
+
headers = [
|
|
829
|
+
("Content-Type", "application/json"),
|
|
830
|
+
("Content-Length", str(len(payload))),
|
|
831
|
+
*_SECURITY_HEADERS,
|
|
832
|
+
*extra_headers,
|
|
833
|
+
]
|
|
834
|
+
start_response(f"{status} {reason}", headers)
|
|
835
|
+
return [payload]
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def _read_body(environ: WSGIEnvironment, length: int) -> bytes | None:
|
|
839
|
+
stream = environ["wsgi.input"]
|
|
840
|
+
chunks: list[bytes] = []
|
|
841
|
+
remaining = length
|
|
842
|
+
while remaining > 0:
|
|
843
|
+
chunk = stream.read(min(remaining, 65536))
|
|
844
|
+
if not chunk:
|
|
845
|
+
return None # client sent less than Content-Length
|
|
846
|
+
chunks.append(chunk)
|
|
847
|
+
remaining -= len(chunk)
|
|
848
|
+
return b"".join(chunks)
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
def create_wsgi_app(
|
|
852
|
+
service: GitHubAppService, *, webhook_path: str = WEBHOOK_PATH
|
|
853
|
+
) -> Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]:
|
|
854
|
+
def application(environ: WSGIEnvironment, start_response: StartResponse) -> Iterable[bytes]:
|
|
855
|
+
path = environ.get("PATH_INFO", "")
|
|
856
|
+
method = environ.get("REQUEST_METHOD", "")
|
|
857
|
+
try:
|
|
858
|
+
if path == "/health":
|
|
859
|
+
if method not in ("GET", "HEAD"):
|
|
860
|
+
return _json_response(start_response, 405, {"error": "method not allowed"})
|
|
861
|
+
return _json_response(start_response, 200, {"status": "ok"})
|
|
862
|
+
if path == "/ready":
|
|
863
|
+
if method not in ("GET", "HEAD"):
|
|
864
|
+
return _json_response(start_response, 405, {"error": "method not allowed"})
|
|
865
|
+
ready, checks = service.readiness()
|
|
866
|
+
return _json_response(
|
|
867
|
+
start_response,
|
|
868
|
+
200 if ready else 503,
|
|
869
|
+
{"status": "ready" if ready else "not_ready", "checks": checks},
|
|
870
|
+
)
|
|
871
|
+
if path != webhook_path:
|
|
872
|
+
return _json_response(start_response, 404, {"error": "not found"})
|
|
873
|
+
if method != "POST":
|
|
874
|
+
return _json_response(
|
|
875
|
+
start_response, 405, {"error": "method not allowed"}, [("Allow", "POST")]
|
|
876
|
+
)
|
|
877
|
+
content_type = environ.get("CONTENT_TYPE", "").split(";", 1)[0].strip().lower()
|
|
878
|
+
if content_type != "application/json":
|
|
879
|
+
return _json_response(start_response, 415, {"error": "expected application/json"})
|
|
880
|
+
raw_length = environ.get("CONTENT_LENGTH", "")
|
|
881
|
+
if not raw_length or not raw_length.isascii() or not raw_length.isdigit():
|
|
882
|
+
return _json_response(start_response, 411, {"error": "content length required"})
|
|
883
|
+
length = int(raw_length)
|
|
884
|
+
if length > MAX_WEBHOOK_BYTES:
|
|
885
|
+
return _json_response(start_response, 413, {"error": "payload too large"})
|
|
886
|
+
body = _read_body(environ, length)
|
|
887
|
+
if body is None:
|
|
888
|
+
return _json_response(start_response, 400, {"error": "incomplete body"})
|
|
889
|
+
headers = {
|
|
890
|
+
key[5:].replace("_", "-").lower(): value
|
|
891
|
+
for key, value in environ.items()
|
|
892
|
+
if key.startswith("HTTP_") and isinstance(value, str)
|
|
893
|
+
}
|
|
894
|
+
result = service.handle_webhook(headers, body, remote_addr=environ.get("REMOTE_ADDR"))
|
|
895
|
+
return _json_response(start_response, result.status, result.body)
|
|
896
|
+
except Exception as exc: # noqa: BLE001 - never leak internals to the client
|
|
897
|
+
log.error("http_internal_error", error_type=type(exc).__name__)
|
|
898
|
+
return _json_response(start_response, 500, {"error": "internal error"})
|
|
899
|
+
|
|
900
|
+
return application
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def service_from_environment() -> "GitHubAppService":
|
|
904
|
+
"""Build the service from environment variables.
|
|
905
|
+
|
|
906
|
+
Both entry points (``commitguard github serve`` and the WSGI factory) use
|
|
907
|
+
this, so they cannot be configured differently: notification delivery was
|
|
908
|
+
once loaded only by the WSGI factory, so ``serve`` delivered nothing.
|
|
909
|
+
"""
|
|
910
|
+
from commitguard.notifications.settings import load_notification_settings
|
|
911
|
+
|
|
912
|
+
return GitHubAppService.from_settings(
|
|
913
|
+
load_settings(), notification_settings=load_notification_settings()
|
|
914
|
+
)
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def wsgi_app_from_environment() -> Callable[[WSGIEnvironment, StartResponse], Iterable[bytes]]:
|
|
918
|
+
"""Entry point for WSGI servers: settings from the environment, workers started.
|
|
919
|
+
|
|
920
|
+
With ``COMMITGUARD_DASHBOARD_URL`` set, the dashboard API (and, with
|
|
921
|
+
``COMMITGUARD_DASHBOARD_STATIC_DIR``, the dashboard) is served too.
|
|
922
|
+
|
|
923
|
+
Example (one process, several threads, behind a TLS reverse proxy)::
|
|
924
|
+
|
|
925
|
+
gunicorn --workers 1 --threads 8 \\
|
|
926
|
+
'commitguard.github.app:wsgi_app_from_environment()'
|
|
927
|
+
"""
|
|
928
|
+
from commitguard.api.hosting import build_dashboard, create_server_app
|
|
929
|
+
from commitguard.api.settings import dashboard_enabled, load_dashboard_settings
|
|
930
|
+
|
|
931
|
+
configure_json_logging()
|
|
932
|
+
service = service_from_environment()
|
|
933
|
+
dashboard = build_dashboard(service, load_dashboard_settings()) if dashboard_enabled() else None
|
|
934
|
+
service.start()
|
|
935
|
+
return create_server_app(service, dashboard)
|