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,628 @@
|
|
|
1
|
+
"""ScanWorker: turns a stored scan job into a GitHub Check Run.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
claim job -> authorize installation + repository (down-scoped token, ID lookup)
|
|
6
|
+
-> newest event for this pull request, PR still open? (else: cancelled, no check)
|
|
7
|
+
-> claim the check slot (repository, SHA, name) (a newer job wins; older jobs stop)
|
|
8
|
+
-> create/refresh Check Run "queued"
|
|
9
|
+
-> fetch commit metadata into the mirror (no checkout, no code execution)
|
|
10
|
+
-> plan commit range + trusted policy (same code as the GitHub Action)
|
|
11
|
+
-> verify planned head == check SHA (TOCTOU guard)
|
|
12
|
+
-> Check Run "in_progress" -> scan -> Check Run "completed"
|
|
13
|
+
-> job state + audit events + metrics
|
|
14
|
+
|
|
15
|
+
Stale writes: every Check Run write happens under a per-slot lock after
|
|
16
|
+
verifying that this job still owns the slot. A job for the same commit that
|
|
17
|
+
started later takes ownership, so an older, slower scan can never overwrite a
|
|
18
|
+
newer result. Check Runs are always created with the exact SHA that was
|
|
19
|
+
scanned, so a result can never be attached to a different commit.
|
|
20
|
+
|
|
21
|
+
Failures are classified - authorization, configuration, infrastructure,
|
|
22
|
+
timeout, internal - recorded as job state ``error`` (distinct from a policy
|
|
23
|
+
``failed``) and, whenever a Check Run exists, published as a failing check:
|
|
24
|
+
CommitGuard never reports success for a commit it could not evaluate.
|
|
25
|
+
|
|
26
|
+
Executions: a job is one *execution* of a logical scan. Re-runs (GitHub's
|
|
27
|
+
"Re-run"), manual scans and automatic retries are new executions of the same
|
|
28
|
+
scan that publish to the same (repository, SHA, check name) slot; earlier
|
|
29
|
+
executions stay stored unchanged. Merge group jobs scan ``base..merge group``
|
|
30
|
+
and publish to the merge group commit, which is the SHA the merge queue waits
|
|
31
|
+
on; a merge group GitHub has already destroyed is not scanned.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
import sqlite3
|
|
35
|
+
import threading
|
|
36
|
+
import zlib
|
|
37
|
+
from collections.abc import Callable
|
|
38
|
+
from dataclasses import dataclass
|
|
39
|
+
from datetime import UTC, datetime
|
|
40
|
+
|
|
41
|
+
from commitguard.audit.models import AuditEventType
|
|
42
|
+
from commitguard.ci.context import CIEventKind
|
|
43
|
+
from commitguard.config.sources import MandatoryPolicy
|
|
44
|
+
from commitguard.controlplane.results import ScanResultRecorder, merge_queue_failure
|
|
45
|
+
from commitguard.core.decision import Action
|
|
46
|
+
from commitguard.core.result import Severity
|
|
47
|
+
from commitguard.exceptions.base import CommitGuardError
|
|
48
|
+
from commitguard.exceptions.configuration import ConfigurationError
|
|
49
|
+
from commitguard.exceptions.git import GitError
|
|
50
|
+
from commitguard.exceptions.service import InfrastructureError, ScanError, StaleScanError
|
|
51
|
+
from commitguard.git.repository import Repository
|
|
52
|
+
from commitguard.github.check_runs import (
|
|
53
|
+
completed_output,
|
|
54
|
+
error_output,
|
|
55
|
+
in_progress_output,
|
|
56
|
+
queued_output,
|
|
57
|
+
)
|
|
58
|
+
from commitguard.github.checks import (
|
|
59
|
+
CheckRunConclusion,
|
|
60
|
+
CheckRunOutput,
|
|
61
|
+
CheckRunStatus,
|
|
62
|
+
check_run_create_payload,
|
|
63
|
+
check_run_update_payload,
|
|
64
|
+
)
|
|
65
|
+
from commitguard.github.client import GitHubClient
|
|
66
|
+
from commitguard.github.errors import (
|
|
67
|
+
AuthenticationError,
|
|
68
|
+
AuthorizationError,
|
|
69
|
+
GitHubAPIError,
|
|
70
|
+
safe_text,
|
|
71
|
+
)
|
|
72
|
+
from commitguard.github.installations import AuthorizedRepository, InstallationService
|
|
73
|
+
from commitguard.github.repositories import FetchTimeoutError, MirrorManager
|
|
74
|
+
from commitguard.github.storage import (
|
|
75
|
+
JobState,
|
|
76
|
+
MergeGroupState,
|
|
77
|
+
ScanJob,
|
|
78
|
+
ScanTrigger,
|
|
79
|
+
SqliteStateStore,
|
|
80
|
+
)
|
|
81
|
+
from commitguard.notifications.deduplication import domain_key
|
|
82
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
83
|
+
from commitguard.notifications.outbox import account_for_installation, emit
|
|
84
|
+
from commitguard.observability.logging import correlation, get_logger
|
|
85
|
+
from commitguard.observability.metrics import (
|
|
86
|
+
MERGE_GROUPS_FAILED,
|
|
87
|
+
MERGE_GROUPS_SCANNED,
|
|
88
|
+
POLICY_VIOLATIONS,
|
|
89
|
+
SCANS_CANCELLED,
|
|
90
|
+
SCANS_COMPLETED,
|
|
91
|
+
SCANS_FAILED,
|
|
92
|
+
SCANS_STARTED,
|
|
93
|
+
Metrics,
|
|
94
|
+
)
|
|
95
|
+
from commitguard.policies.governance import EffectivePolicy, GovernanceInputs
|
|
96
|
+
from commitguard.rules.matcher import CompiledRules
|
|
97
|
+
from commitguard.services.audit import AuditService
|
|
98
|
+
from commitguard.services.ci import DEFAULT_CI_MAX_COMMITS
|
|
99
|
+
from commitguard.services.enforcement import FailureKind
|
|
100
|
+
from commitguard.services.scan import ScanRequest, ScanResult, ScanService
|
|
101
|
+
|
|
102
|
+
log = get_logger(__name__)
|
|
103
|
+
|
|
104
|
+
JOB_LEASE_SECONDS = 1800.0
|
|
105
|
+
MAX_JOB_ATTEMPTS = 3
|
|
106
|
+
_LOCK_STRIPES = 64
|
|
107
|
+
#: Cancellation reasons meaning a newer execution superseded this one (result ``stale``).
|
|
108
|
+
SUPERSEDED_NEWER_EVENT = "a newer event for this pull request is waiting to be scanned"
|
|
109
|
+
SUPERSEDED_CHECK_OWNER = "a newer scan owns the check for this commit"
|
|
110
|
+
MERGE_GROUP_GONE = "the merge queue no longer waits for this merge group"
|
|
111
|
+
_SUPERSEDED = frozenset({SUPERSEDED_NEWER_EVENT, SUPERSEDED_CHECK_OWNER, MERGE_GROUP_GONE})
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
type PolicyResolver = Callable[[int], tuple[MandatoryPolicy | None, int | None]]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class ScanGovernance:
|
|
119
|
+
"""Organization governance for one scan (see commitguard.governance.resolver)."""
|
|
120
|
+
|
|
121
|
+
inputs: GovernanceInputs
|
|
122
|
+
organization_policy_version: int | None
|
|
123
|
+
fingerprint: str
|
|
124
|
+
#: Builds the immutable record stored with the scan from the resolved effective policy.
|
|
125
|
+
record: Callable[[EffectivePolicy | None], str]
|
|
126
|
+
#: The organization's rules (bundled rules plus its identity data), when it has any.
|
|
127
|
+
rules: CompiledRules | None = None
|
|
128
|
+
rules_version: str | None = None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
type GovernanceResolverFn = Callable[[int, int], ScanGovernance | None]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class _Progress:
|
|
136
|
+
auth: AuthorizedRepository | None = None
|
|
137
|
+
check_run_id: int | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class ScanWorker:
|
|
141
|
+
def __init__(
|
|
142
|
+
self,
|
|
143
|
+
*,
|
|
144
|
+
store: SqliteStateStore,
|
|
145
|
+
installations: InstallationService,
|
|
146
|
+
client: GitHubClient,
|
|
147
|
+
mirrors: MirrorManager,
|
|
148
|
+
audit: AuditService,
|
|
149
|
+
metrics: Metrics,
|
|
150
|
+
scan_service: ScanService | None = None,
|
|
151
|
+
mandatory_policy: MandatoryPolicy | None = None,
|
|
152
|
+
policy_resolver: PolicyResolver | None = None,
|
|
153
|
+
governance_resolver: GovernanceResolverFn | None = None,
|
|
154
|
+
recorder: ScanResultRecorder | None = None,
|
|
155
|
+
max_commits: int = DEFAULT_CI_MAX_COMMITS,
|
|
156
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
157
|
+
) -> None:
|
|
158
|
+
self._store = store
|
|
159
|
+
self._installations = installations
|
|
160
|
+
self._client = client
|
|
161
|
+
self._mirrors = mirrors
|
|
162
|
+
self._audit = audit
|
|
163
|
+
self._metrics = metrics
|
|
164
|
+
self._scans = scan_service or ScanService()
|
|
165
|
+
# The floor for an installation: service policy + its organisation's policy.
|
|
166
|
+
self._policy_resolver: PolicyResolver = policy_resolver or (
|
|
167
|
+
lambda _installation_id: (mandatory_policy, None)
|
|
168
|
+
)
|
|
169
|
+
# Organization governance per repository (GitHub App); replaces the floor above.
|
|
170
|
+
self._governance_resolver = governance_resolver
|
|
171
|
+
self._recorder = recorder or ScanResultRecorder(store, audit, now=now)
|
|
172
|
+
self._max_commits = max_commits
|
|
173
|
+
self._now = now
|
|
174
|
+
self._locks = tuple(threading.Lock() for _ in range(_LOCK_STRIPES))
|
|
175
|
+
|
|
176
|
+
# ------------------------------------------------------------------ #
|
|
177
|
+
def process(self, job_id: str) -> JobState | None:
|
|
178
|
+
"""Run one job. Returns its final state, or None if it was not claimable."""
|
|
179
|
+
outcome = self._store.claim_job_outcome(
|
|
180
|
+
job_id, self._now(), JOB_LEASE_SECONDS, MAX_JOB_ATTEMPTS
|
|
181
|
+
)
|
|
182
|
+
if outcome.exhausted is not None:
|
|
183
|
+
self._exhausted(outcome.exhausted)
|
|
184
|
+
return None
|
|
185
|
+
job = outcome.job
|
|
186
|
+
if job is None:
|
|
187
|
+
return None
|
|
188
|
+
with correlation(
|
|
189
|
+
job_id=job.job_id,
|
|
190
|
+
delivery_id=job.delivery_id,
|
|
191
|
+
installation_id=job.installation_id,
|
|
192
|
+
repository=job.repository.full_name,
|
|
193
|
+
):
|
|
194
|
+
self._metrics.increment(SCANS_STARTED)
|
|
195
|
+
progress = _Progress()
|
|
196
|
+
try:
|
|
197
|
+
return self._run(job, progress)
|
|
198
|
+
except StaleScanError as exc:
|
|
199
|
+
return self._cancel(job, str(exc))
|
|
200
|
+
except (AuthorizationError, AuthenticationError) as exc:
|
|
201
|
+
return self._fail(job, progress, FailureKind.AUTHORIZATION, exc)
|
|
202
|
+
except ConfigurationError as exc:
|
|
203
|
+
return self._fail(job, progress, FailureKind.CONFIGURATION, exc)
|
|
204
|
+
except FetchTimeoutError as exc:
|
|
205
|
+
return self._fail(job, progress, FailureKind.TIMEOUT, exc)
|
|
206
|
+
except (InfrastructureError, GitHubAPIError, GitError, ScanError) as exc:
|
|
207
|
+
return self._fail(job, progress, FailureKind.INFRASTRUCTURE, exc)
|
|
208
|
+
except Exception as exc: # noqa: BLE001 - any failure must fail closed
|
|
209
|
+
return self._fail(job, progress, FailureKind.INTERNAL, exc)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------ #
|
|
212
|
+
def _slot(self, job: ScanJob, repository_id: int) -> tuple[int, int, str, str]:
|
|
213
|
+
return (job.installation_id, repository_id, job.head_sha, job.check_name)
|
|
214
|
+
|
|
215
|
+
def _lock_for(self, slot: tuple[int, int, str, str]) -> threading.Lock:
|
|
216
|
+
index = zlib.crc32(repr(slot).encode("utf-8")) % _LOCK_STRIPES
|
|
217
|
+
return self._locks[index]
|
|
218
|
+
|
|
219
|
+
def _run(self, job: ScanJob, progress: _Progress) -> JobState:
|
|
220
|
+
if not self._store.monitoring_enabled(job.installation_id, job.repository.id):
|
|
221
|
+
raise StaleScanError("CommitGuard monitoring is paused for this repository")
|
|
222
|
+
if job.event == "merge_group":
|
|
223
|
+
group = self._store.get_merge_group(
|
|
224
|
+
job.installation_id, job.repository.id, job.head_sha
|
|
225
|
+
)
|
|
226
|
+
if group is None or group.state is MergeGroupState.DESTROYED:
|
|
227
|
+
raise StaleScanError(MERGE_GROUP_GONE)
|
|
228
|
+
if job.trigger in (ScanTrigger.MANUAL, ScanTrigger.RERUN, ScanTrigger.RETRY):
|
|
229
|
+
self._audit_job(
|
|
230
|
+
AuditEventType.SCAN_STARTED,
|
|
231
|
+
job,
|
|
232
|
+
trigger=job.trigger.value,
|
|
233
|
+
execution=job.execution,
|
|
234
|
+
requested_by=job.requested_by,
|
|
235
|
+
)
|
|
236
|
+
auth = self._installations.authorize(job.installation_id, job.repository)
|
|
237
|
+
progress.auth = auth
|
|
238
|
+
repository = auth.repository
|
|
239
|
+
token = auth.token.token
|
|
240
|
+
|
|
241
|
+
if job.pull_request_number is not None:
|
|
242
|
+
latest = self._store.latest_group_sequence(
|
|
243
|
+
job.installation_id, repository.id, job.group_key
|
|
244
|
+
)
|
|
245
|
+
if latest > job.sequence:
|
|
246
|
+
raise StaleScanError(SUPERSEDED_NEWER_EVENT)
|
|
247
|
+
pull_request = self._client.get_pull_request(token, repository, job.pull_request_number)
|
|
248
|
+
if pull_request.state != "open":
|
|
249
|
+
raise StaleScanError("the pull request is no longer open")
|
|
250
|
+
if pull_request.head.sha != job.head_sha:
|
|
251
|
+
# The API can lag behind webhooks, so this alone does not cancel the scan:
|
|
252
|
+
# a newer head arrives as its own event, and this result is attached to
|
|
253
|
+
# exactly the commit that was scanned.
|
|
254
|
+
log.info("pull_request_head_differs", commit=job.head_sha[:12])
|
|
255
|
+
|
|
256
|
+
slot = self._slot(job, repository.id)
|
|
257
|
+
with self._lock_for(slot):
|
|
258
|
+
claim = self._store.claim_check(*slot, job.sequence, self._now())
|
|
259
|
+
if not claim.owned:
|
|
260
|
+
raise StaleScanError(SUPERSEDED_CHECK_OWNER)
|
|
261
|
+
queued = queued_output(self._describe(job))
|
|
262
|
+
if claim.check_run_id is None:
|
|
263
|
+
created = self._client.create_check_run(
|
|
264
|
+
token,
|
|
265
|
+
repository,
|
|
266
|
+
check_run_create_payload(
|
|
267
|
+
name=job.check_name,
|
|
268
|
+
head_sha=job.head_sha,
|
|
269
|
+
external_id=job.job_id,
|
|
270
|
+
output=queued,
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
if created.head_sha != job.head_sha:
|
|
274
|
+
raise ScanError("GitHub attached the check run to a different commit")
|
|
275
|
+
if not self._store.set_check_run_id(*slot, job.sequence, created.id):
|
|
276
|
+
raise StaleScanError(SUPERSEDED_CHECK_OWNER)
|
|
277
|
+
progress.check_run_id = created.id
|
|
278
|
+
else:
|
|
279
|
+
progress.check_run_id = claim.check_run_id
|
|
280
|
+
self._client.update_check_run(
|
|
281
|
+
token,
|
|
282
|
+
repository,
|
|
283
|
+
claim.check_run_id,
|
|
284
|
+
check_run_update_payload(CheckRunStatus.QUEUED, queued),
|
|
285
|
+
)
|
|
286
|
+
self._store.update_job(job.job_id, self._now(), check_run_id=progress.check_run_id)
|
|
287
|
+
|
|
288
|
+
context = job.context
|
|
289
|
+
if context.event in (CIEventKind.PULL_REQUEST, CIEventKind.MERGE_GROUP):
|
|
290
|
+
# Merge groups: base..merge group commit, policy from the base (the trusted
|
|
291
|
+
# target branch), results on the merge group commit the queue waits on.
|
|
292
|
+
required = [s for s in (context.base_sha, context.head_sha) if s]
|
|
293
|
+
optional: list[str] = []
|
|
294
|
+
branches: list[str] = []
|
|
295
|
+
else:
|
|
296
|
+
required = [context.after_sha] if context.after_sha else []
|
|
297
|
+
optional = [context.before_sha] if context.before_sha else []
|
|
298
|
+
branches = [auth.default_branch] if auth.default_branch else []
|
|
299
|
+
# The default branch used to limit a new branch's range comes from the API.
|
|
300
|
+
context = context.model_copy(update={"default_branch": auth.default_branch})
|
|
301
|
+
mirror = self._mirrors.prepare(
|
|
302
|
+
job.installation_id,
|
|
303
|
+
repository,
|
|
304
|
+
token,
|
|
305
|
+
required=required,
|
|
306
|
+
optional=optional,
|
|
307
|
+
branches=branches,
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
governance = (
|
|
311
|
+
self._governance_resolver(job.installation_id, repository.id)
|
|
312
|
+
if self._governance_resolver is not None
|
|
313
|
+
else None
|
|
314
|
+
)
|
|
315
|
+
if governance is not None:
|
|
316
|
+
# Resolution failures raise and fail the scan closed: never scan without policy.
|
|
317
|
+
mandatory, organization_policy_version = None, governance.organization_policy_version
|
|
318
|
+
else:
|
|
319
|
+
mandatory, organization_policy_version = self._policy_resolver(job.installation_id)
|
|
320
|
+
request = ScanRequest(
|
|
321
|
+
repository=mirror,
|
|
322
|
+
context=context,
|
|
323
|
+
max_commits=self._max_commits,
|
|
324
|
+
mandatory_policy=mandatory,
|
|
325
|
+
governance=governance.inputs if governance is not None else None,
|
|
326
|
+
rules=governance.rules if governance is not None else None,
|
|
327
|
+
rules_version=governance.rules_version if governance is not None else None,
|
|
328
|
+
)
|
|
329
|
+
plan = self._scans.plan(request)
|
|
330
|
+
if plan.range.head is not None and plan.range.head != job.head_sha:
|
|
331
|
+
raise ScanError("the planned commit does not match the commit the check belongs to")
|
|
332
|
+
self._publish(
|
|
333
|
+
job,
|
|
334
|
+
progress,
|
|
335
|
+
CheckRunStatus.IN_PROGRESS,
|
|
336
|
+
in_progress_output(len(plan.range.commits), str(plan.policy_source)),
|
|
337
|
+
)
|
|
338
|
+
result = self._scans.execute(request, plan)
|
|
339
|
+
with correlation(scan_id=result.metadata.scan_id):
|
|
340
|
+
conclusion, output = completed_output(result)
|
|
341
|
+
self._publish(job, progress, CheckRunStatus.COMPLETED, output, conclusion)
|
|
342
|
+
return self._record_result(
|
|
343
|
+
job, result, conclusion, mirror, organization_policy_version, governance
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
def _describe(self, job: ScanJob) -> str:
|
|
347
|
+
if job.event == "merge_group":
|
|
348
|
+
return f"the merge queue's merge group at {job.head_sha[:12]}"
|
|
349
|
+
if job.pull_request_number is not None:
|
|
350
|
+
return f"pull request #{job.pull_request_number} at {job.head_sha[:12]}"
|
|
351
|
+
return f"the push of {job.head_sha[:12]}"
|
|
352
|
+
|
|
353
|
+
def _publish(
|
|
354
|
+
self,
|
|
355
|
+
job: ScanJob,
|
|
356
|
+
progress: _Progress,
|
|
357
|
+
status: CheckRunStatus,
|
|
358
|
+
output: CheckRunOutput,
|
|
359
|
+
conclusion: CheckRunConclusion | None = None,
|
|
360
|
+
) -> None:
|
|
361
|
+
if progress.auth is None or progress.check_run_id is None:
|
|
362
|
+
raise ScanError("no check run to update")
|
|
363
|
+
slot = self._slot(job, progress.auth.repository.id)
|
|
364
|
+
with self._lock_for(slot):
|
|
365
|
+
if self._store.check_owner(*slot) != job.sequence:
|
|
366
|
+
raise StaleScanError(SUPERSEDED_CHECK_OWNER)
|
|
367
|
+
self._client.update_check_run(
|
|
368
|
+
progress.auth.token.token,
|
|
369
|
+
progress.auth.repository,
|
|
370
|
+
progress.check_run_id,
|
|
371
|
+
check_run_update_payload(status, output, conclusion),
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
def _record_result(
|
|
375
|
+
self,
|
|
376
|
+
job: ScanJob,
|
|
377
|
+
result: ScanResult,
|
|
378
|
+
conclusion: CheckRunConclusion,
|
|
379
|
+
repository: Repository,
|
|
380
|
+
organization_policy_version: int | None,
|
|
381
|
+
governance: ScanGovernance | None = None,
|
|
382
|
+
) -> JobState:
|
|
383
|
+
stats = result.statistics
|
|
384
|
+
state = JobState.PASSED if result.enforcement.allowed else JobState.FAILED
|
|
385
|
+
# Job state, findings and the violation lifecycle change in one transaction.
|
|
386
|
+
self._recorder.record_completed(
|
|
387
|
+
job,
|
|
388
|
+
result,
|
|
389
|
+
state=state,
|
|
390
|
+
conclusion=conclusion.value,
|
|
391
|
+
repository=repository,
|
|
392
|
+
organization_policy_version=organization_policy_version,
|
|
393
|
+
governance_record=governance.record(result.effective) if governance else None,
|
|
394
|
+
governance_fingerprint=governance.fingerprint if governance else None,
|
|
395
|
+
)
|
|
396
|
+
self._audit_job(
|
|
397
|
+
AuditEventType.REPOSITORY_SCANNED,
|
|
398
|
+
job,
|
|
399
|
+
action=result.action,
|
|
400
|
+
commits_scanned=stats.commits_scanned,
|
|
401
|
+
rules_version=result.metadata.rules_version[:16],
|
|
402
|
+
policy_version=result.metadata.policy_version[:16],
|
|
403
|
+
organization_policy_version=organization_policy_version,
|
|
404
|
+
trigger=job.trigger.value,
|
|
405
|
+
execution=job.execution,
|
|
406
|
+
)
|
|
407
|
+
self._audit_job(
|
|
408
|
+
AuditEventType.SCAN_PASSED if state is JobState.PASSED else AuditEventType.SCAN_FAILED,
|
|
409
|
+
job,
|
|
410
|
+
action=result.action,
|
|
411
|
+
violations=stats.violations,
|
|
412
|
+
warnings=stats.warnings,
|
|
413
|
+
conclusion=conclusion.value,
|
|
414
|
+
trigger=job.trigger.value,
|
|
415
|
+
execution=job.execution,
|
|
416
|
+
organization_policy_version=organization_policy_version,
|
|
417
|
+
)
|
|
418
|
+
if job.event == "merge_group":
|
|
419
|
+
self._metrics.increment(MERGE_GROUPS_SCANNED, result=result.action.value)
|
|
420
|
+
self._audit_job(
|
|
421
|
+
AuditEventType.MERGE_GROUP_PASSED
|
|
422
|
+
if state is JobState.PASSED
|
|
423
|
+
else AuditEventType.MERGE_GROUP_BLOCKED,
|
|
424
|
+
job,
|
|
425
|
+
action=result.action,
|
|
426
|
+
violations=stats.violations,
|
|
427
|
+
pull_requests=self._merge_group_prs(job),
|
|
428
|
+
)
|
|
429
|
+
if state is JobState.FAILED:
|
|
430
|
+
self._metrics.increment(MERGE_GROUPS_FAILED, reason="blocked")
|
|
431
|
+
blocked = [
|
|
432
|
+
f
|
|
433
|
+
for commit in result.report.commits
|
|
434
|
+
for f in commit.findings
|
|
435
|
+
if f.action is Action.BLOCK
|
|
436
|
+
]
|
|
437
|
+
if blocked:
|
|
438
|
+
self._metrics.increment(POLICY_VIOLATIONS, len(blocked))
|
|
439
|
+
self._audit_job(
|
|
440
|
+
AuditEventType.POLICY_VIOLATION,
|
|
441
|
+
job,
|
|
442
|
+
action=Action.BLOCK,
|
|
443
|
+
rules=",".join(sorted({f.finding.rule_id for f in blocked})),
|
|
444
|
+
findings=len(blocked),
|
|
445
|
+
fingerprints=",".join(sorted(f.fingerprint[:16] for f in blocked)[:20]),
|
|
446
|
+
)
|
|
447
|
+
if result.plan.policy_weakenings:
|
|
448
|
+
self._audit_job(
|
|
449
|
+
AuditEventType.POLICY_MODIFICATION,
|
|
450
|
+
job,
|
|
451
|
+
changes="; ".join(result.plan.policy_weakenings),
|
|
452
|
+
)
|
|
453
|
+
self._metrics.increment(SCANS_COMPLETED, result=result.action.value)
|
|
454
|
+
log.info(
|
|
455
|
+
"scan_completed",
|
|
456
|
+
commit=job.head_sha[:12],
|
|
457
|
+
result=result.action.value,
|
|
458
|
+
conclusion=conclusion.value,
|
|
459
|
+
commits=stats.commits_scanned,
|
|
460
|
+
violations=stats.violations,
|
|
461
|
+
warnings=stats.warnings,
|
|
462
|
+
)
|
|
463
|
+
return state
|
|
464
|
+
|
|
465
|
+
def _merge_group_prs(self, job: ScanJob) -> str | None:
|
|
466
|
+
group = self._store.get_merge_group(job.installation_id, job.repository.id, job.head_sha)
|
|
467
|
+
if group is None or not group.pull_requests:
|
|
468
|
+
return None
|
|
469
|
+
return ",".join(f"#{n}" for n in group.pull_requests)
|
|
470
|
+
|
|
471
|
+
def _cancel(self, job: ScanJob, reason: str) -> JobState:
|
|
472
|
+
self._store.update_job(
|
|
473
|
+
job.job_id,
|
|
474
|
+
self._now(),
|
|
475
|
+
state=JobState.CANCELLED,
|
|
476
|
+
message=safe_text(reason),
|
|
477
|
+
failure_kind="stale" if reason in _SUPERSEDED else None,
|
|
478
|
+
lease_expires_at=None,
|
|
479
|
+
completed_at=self._now().timestamp(),
|
|
480
|
+
)
|
|
481
|
+
self._metrics.increment(SCANS_CANCELLED)
|
|
482
|
+
self._audit_job(AuditEventType.SCAN_CANCELLED, job, reason=reason)
|
|
483
|
+
log.info("scan_cancelled", commit=job.head_sha[:12], reason=reason)
|
|
484
|
+
return JobState.CANCELLED
|
|
485
|
+
|
|
486
|
+
def _fail(
|
|
487
|
+
self, job: ScanJob, progress: _Progress, kind: FailureKind, exc: BaseException
|
|
488
|
+
) -> JobState:
|
|
489
|
+
if isinstance(exc, CommitGuardError):
|
|
490
|
+
reason = safe_text(str(exc), 500)
|
|
491
|
+
else:
|
|
492
|
+
reason = f"unexpected error ({type(exc).__name__})"
|
|
493
|
+
now = self._now()
|
|
494
|
+
with self._store.transaction() as db:
|
|
495
|
+
self._store.update_job_in(
|
|
496
|
+
db,
|
|
497
|
+
job.job_id,
|
|
498
|
+
now,
|
|
499
|
+
state=JobState.ERROR,
|
|
500
|
+
failure_kind=kind.value,
|
|
501
|
+
message=reason,
|
|
502
|
+
lease_expires_at=None,
|
|
503
|
+
completed_at=now.timestamp(),
|
|
504
|
+
)
|
|
505
|
+
self._emit_failure_notifications(db, job, reason, now)
|
|
506
|
+
self._metrics.increment(SCANS_FAILED, kind=kind.value)
|
|
507
|
+
if job.event == "merge_group":
|
|
508
|
+
self._metrics.increment(MERGE_GROUPS_FAILED, reason="error")
|
|
509
|
+
self._audit_job(
|
|
510
|
+
AuditEventType.MERGE_GROUP_SCAN_FAILED,
|
|
511
|
+
job,
|
|
512
|
+
failure_kind=kind.value,
|
|
513
|
+
reason=reason,
|
|
514
|
+
pull_requests=self._merge_group_prs(job),
|
|
515
|
+
)
|
|
516
|
+
log.warning(
|
|
517
|
+
"scan_error",
|
|
518
|
+
exc=exc if isinstance(exc, CommitGuardError) else None,
|
|
519
|
+
failure_kind=kind.value,
|
|
520
|
+
error_type=type(exc).__name__,
|
|
521
|
+
commit=job.head_sha[:12],
|
|
522
|
+
)
|
|
523
|
+
event_type = {
|
|
524
|
+
FailureKind.AUTHORIZATION: AuditEventType.AUTHORIZATION_DENIED,
|
|
525
|
+
FailureKind.CONFIGURATION: AuditEventType.CONFIGURATION_ERROR,
|
|
526
|
+
}.get(kind, AuditEventType.SCAN_ERROR)
|
|
527
|
+
self._audit_job(event_type, job, failure_kind=kind.value, reason=reason)
|
|
528
|
+
if progress.auth is not None and progress.check_run_id is not None:
|
|
529
|
+
conclusion, output = error_output(kind, reason)
|
|
530
|
+
try:
|
|
531
|
+
self._publish(job, progress, CheckRunStatus.COMPLETED, output, conclusion)
|
|
532
|
+
except StaleScanError:
|
|
533
|
+
pass # a newer scan owns the check and will publish its own result
|
|
534
|
+
except Exception as publish_error: # noqa: BLE001 - best effort; already failed closed
|
|
535
|
+
log.error(
|
|
536
|
+
"check_run_failure_not_published",
|
|
537
|
+
error_type=type(publish_error).__name__,
|
|
538
|
+
commit=job.head_sha[:12],
|
|
539
|
+
)
|
|
540
|
+
return JobState.ERROR
|
|
541
|
+
|
|
542
|
+
def _emit_failure_notifications(
|
|
543
|
+
self, db: sqlite3.Connection, job: ScanJob, reason: str, now: datetime
|
|
544
|
+
) -> None:
|
|
545
|
+
"""Operational failures that someone is waiting on (never a security decision)."""
|
|
546
|
+
account_id = account_for_installation(db, job.installation_id)
|
|
547
|
+
if account_id is None:
|
|
548
|
+
return
|
|
549
|
+
if job.event == "merge_group":
|
|
550
|
+
emit(
|
|
551
|
+
db,
|
|
552
|
+
merge_queue_failure(
|
|
553
|
+
job,
|
|
554
|
+
account_id,
|
|
555
|
+
f"CommitGuard could not validate the merge group ({reason}). The check "
|
|
556
|
+
"failed closed, so the merge queue cannot merge it.",
|
|
557
|
+
),
|
|
558
|
+
now,
|
|
559
|
+
)
|
|
560
|
+
elif job.trigger is ScanTrigger.RERUN:
|
|
561
|
+
emit(
|
|
562
|
+
db,
|
|
563
|
+
NotificationEvent(
|
|
564
|
+
type=NotificationType.CHECK_RERUN_FAILED,
|
|
565
|
+
account_id=account_id,
|
|
566
|
+
severity=Severity.MEDIUM,
|
|
567
|
+
installation_id=job.installation_id,
|
|
568
|
+
repository_id=job.repository.id,
|
|
569
|
+
resource_type="scan",
|
|
570
|
+
resource_id=job.job_id,
|
|
571
|
+
dedup_key=domain_key(NotificationType.CHECK_RERUN_FAILED, job.job_id),
|
|
572
|
+
title=f"Check re-run failed in {job.repository.full_name}",
|
|
573
|
+
body=(
|
|
574
|
+
f"The re-run of {job.check_name} at {job.head_sha[:12]} could not be "
|
|
575
|
+
f"completed: {reason}. The check failed closed."
|
|
576
|
+
),
|
|
577
|
+
metadata={"scan": job.job_id, "execution": job.execution},
|
|
578
|
+
),
|
|
579
|
+
now,
|
|
580
|
+
)
|
|
581
|
+
|
|
582
|
+
def _exhausted(self, job: ScanJob) -> None:
|
|
583
|
+
"""A job that crashed or timed out on every attempt: audit it and fail its check."""
|
|
584
|
+
reason = "scan abandoned after repeated attempts"
|
|
585
|
+
with correlation(
|
|
586
|
+
job_id=job.job_id,
|
|
587
|
+
installation_id=job.installation_id,
|
|
588
|
+
repository=job.repository.full_name,
|
|
589
|
+
):
|
|
590
|
+
self._metrics.increment(SCANS_FAILED, kind=FailureKind.INTERNAL.value)
|
|
591
|
+
self._audit_job(
|
|
592
|
+
AuditEventType.SCAN_ERROR,
|
|
593
|
+
job,
|
|
594
|
+
failure_kind=FailureKind.INTERNAL.value,
|
|
595
|
+
reason=reason,
|
|
596
|
+
attempts=job.attempts,
|
|
597
|
+
)
|
|
598
|
+
with self._store.transaction() as db:
|
|
599
|
+
self._emit_failure_notifications(db, job, reason, self._now())
|
|
600
|
+
if job.check_run_id is None:
|
|
601
|
+
return
|
|
602
|
+
try:
|
|
603
|
+
auth = self._installations.authorize(job.installation_id, job.repository)
|
|
604
|
+
progress = _Progress(auth=auth, check_run_id=job.check_run_id)
|
|
605
|
+
conclusion, output = error_output(FailureKind.INTERNAL, reason)
|
|
606
|
+
self._publish(job, progress, CheckRunStatus.COMPLETED, output, conclusion)
|
|
607
|
+
except StaleScanError:
|
|
608
|
+
pass # a newer execution owns the check
|
|
609
|
+
except Exception as exc: # noqa: BLE001 - best effort; the job is already an error
|
|
610
|
+
log.error("check_run_failure_not_published", error_type=type(exc).__name__)
|
|
611
|
+
|
|
612
|
+
def _audit_job(
|
|
613
|
+
self,
|
|
614
|
+
event_type: AuditEventType,
|
|
615
|
+
job: ScanJob,
|
|
616
|
+
*,
|
|
617
|
+
action: Action | None = None,
|
|
618
|
+
**data: str | int | bool | None,
|
|
619
|
+
) -> None:
|
|
620
|
+
self._audit.record(
|
|
621
|
+
event_type,
|
|
622
|
+
installation_id=job.installation_id,
|
|
623
|
+
repository_id=job.repository.id,
|
|
624
|
+
repository=job.repository.full_name,
|
|
625
|
+
head_sha=job.head_sha,
|
|
626
|
+
action=action,
|
|
627
|
+
extra=data,
|
|
628
|
+
)
|