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,1814 @@
|
|
|
1
|
+
"""Read-side services for the dashboard API.
|
|
2
|
+
|
|
3
|
+
Tenant isolation
|
|
4
|
+
================
|
|
5
|
+
|
|
6
|
+
Every public method takes an :class:`~commitguard.controlplane.access.AccessScope`
|
|
7
|
+
and every statement filters on it:
|
|
8
|
+
|
|
9
|
+
* ``installation_id IN (SELECT value FROM json_each(?))`` - installations of
|
|
10
|
+
accounts where the user holds the required permission and that GitHub
|
|
11
|
+
reported as accessible at sign-in;
|
|
12
|
+
* ``EXISTS (SELECT 1 FROM session_repositories ...)`` - the repository was in
|
|
13
|
+
the user's GitHub-reported repository list for this session.
|
|
14
|
+
|
|
15
|
+
A row outside the scope is indistinguishable from a row that does not exist:
|
|
16
|
+
detail lookups return ``None`` (the API answers 404) rather than "forbidden".
|
|
17
|
+
|
|
18
|
+
SQL safety: statements are assembled only from the constant fragments in this
|
|
19
|
+
module (filters present or absent, one of a fixed set of ``ORDER BY`` clauses);
|
|
20
|
+
every value, including search text and cursor positions, is a bound
|
|
21
|
+
parameter. Search uses ``LIKE ... ESCAPE '\\'`` on escaped input.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from datetime import UTC, datetime, timedelta
|
|
28
|
+
from sqlite3 import Row
|
|
29
|
+
from typing import Any, Literal
|
|
30
|
+
|
|
31
|
+
from commitguard.audit.models import AuditEvent, AuditEventType
|
|
32
|
+
from commitguard.controlplane.access import AccessScope, Permission, Principal
|
|
33
|
+
from commitguard.controlplane.pagination import (
|
|
34
|
+
Page,
|
|
35
|
+
decode_cursor,
|
|
36
|
+
encode_cursor,
|
|
37
|
+
like_pattern,
|
|
38
|
+
sha_prefix,
|
|
39
|
+
)
|
|
40
|
+
from commitguard.controlplane.results import scan_result_label
|
|
41
|
+
from commitguard.controlplane.rules import remediation_steps
|
|
42
|
+
from commitguard.controlplane.views import (
|
|
43
|
+
AcknowledgementView,
|
|
44
|
+
ActorView,
|
|
45
|
+
AppConnection,
|
|
46
|
+
AuditEventView,
|
|
47
|
+
DetectionView,
|
|
48
|
+
EnforcementSignal,
|
|
49
|
+
EnforcementView,
|
|
50
|
+
EvidenceView,
|
|
51
|
+
ExecutionHistory,
|
|
52
|
+
ExecutionView,
|
|
53
|
+
ExposureView,
|
|
54
|
+
FindingView,
|
|
55
|
+
HealthCheck,
|
|
56
|
+
HealthCheckStatus,
|
|
57
|
+
InstallationDetail,
|
|
58
|
+
InstallationRepositoryView,
|
|
59
|
+
InstallationView,
|
|
60
|
+
IntegrationStatus,
|
|
61
|
+
IntegrationView,
|
|
62
|
+
LatestCheckSignal,
|
|
63
|
+
MatchView,
|
|
64
|
+
MergeGroupView,
|
|
65
|
+
MergeQueueStatus,
|
|
66
|
+
MergeQueueView,
|
|
67
|
+
OrganizationRef,
|
|
68
|
+
OverviewPeriod,
|
|
69
|
+
OverviewSummary,
|
|
70
|
+
OverviewView,
|
|
71
|
+
PolicyEntry,
|
|
72
|
+
ProtectionStatus,
|
|
73
|
+
RepositoryDetail,
|
|
74
|
+
RepositoryLink,
|
|
75
|
+
RepositoryPermissions,
|
|
76
|
+
RepositorySummary,
|
|
77
|
+
RequiredCheckSignal,
|
|
78
|
+
RequiredCheckStatus,
|
|
79
|
+
ScanComparison,
|
|
80
|
+
ScanDetail,
|
|
81
|
+
ScanFailure,
|
|
82
|
+
ScanResultStatus,
|
|
83
|
+
ScanSummary,
|
|
84
|
+
ViolationDetail,
|
|
85
|
+
ViolationStatus,
|
|
86
|
+
ViolationSummary,
|
|
87
|
+
)
|
|
88
|
+
from commitguard.core.decision import Action
|
|
89
|
+
from commitguard.core.result import Severity
|
|
90
|
+
from commitguard.github.permissions import (
|
|
91
|
+
REQUIRED_PERMISSIONS,
|
|
92
|
+
excessive_permissions,
|
|
93
|
+
level_rank,
|
|
94
|
+
missing_permissions,
|
|
95
|
+
)
|
|
96
|
+
from commitguard.github.storage import MergeGroupRecord, SqliteStateStore
|
|
97
|
+
|
|
98
|
+
SCOPE_JOBS = (
|
|
99
|
+
"j.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 FROM "
|
|
100
|
+
"session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = j.installation_id "
|
|
101
|
+
"AND sr.repository_id = j.repository_id)"
|
|
102
|
+
)
|
|
103
|
+
SCOPE_VIOLATIONS = (
|
|
104
|
+
"v.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 FROM "
|
|
105
|
+
"session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = v.installation_id "
|
|
106
|
+
"AND sr.repository_id = v.repository_id)"
|
|
107
|
+
)
|
|
108
|
+
SCOPE_REPOSITORIES = (
|
|
109
|
+
"r.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 FROM "
|
|
110
|
+
"session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = r.installation_id "
|
|
111
|
+
"AND sr.repository_id = r.repository_id)"
|
|
112
|
+
)
|
|
113
|
+
SCOPE_AUDIT = (
|
|
114
|
+
"a.account_id IN (SELECT value FROM json_each(?)) AND (a.installation_id IS NULL OR "
|
|
115
|
+
"a.installation_id IN (SELECT value FROM json_each(?))) AND (a.repository_id IS NULL OR "
|
|
116
|
+
"EXISTS (SELECT 1 FROM session_repositories sr WHERE sr.session_hash = ? AND "
|
|
117
|
+
"sr.installation_id = a.installation_id AND sr.repository_id = a.repository_id))"
|
|
118
|
+
)
|
|
119
|
+
SCOPE_INSTALLATIONS = "i.installation_id IN (SELECT value FROM json_each(?))"
|
|
120
|
+
|
|
121
|
+
_SCAN_COLUMNS = (
|
|
122
|
+
"j.job_id, j.scan_id, j.installation_id, j.repository_id, j.owner, j.name, j.event, "
|
|
123
|
+
"j.pull_request_number, j.ref, j.base_sha, j.head_sha, j.check_name, j.state, "
|
|
124
|
+
"j.result_action, j.commits_scanned, j.violations, j.warnings, j.findings_count, "
|
|
125
|
+
"j.created_at, j.started_at, j.completed_at, j.requested_by, j.sequence, j.group_key, "
|
|
126
|
+
"j.conclusion, j.failure_kind, j.message, j.tool_version, j.rules_version, j.policy_version, "
|
|
127
|
+
"j.policy_source, j.organization_policy_version, j.effective_policies, "
|
|
128
|
+
"j.detector_failures, j.notices, j.scan_key, j.execution, j.trigger_kind, "
|
|
129
|
+
"(SELECT account_id FROM installations i WHERE "
|
|
130
|
+
"i.installation_id = j.installation_id) AS account_id"
|
|
131
|
+
)
|
|
132
|
+
_VIOLATION_COLUMNS = (
|
|
133
|
+
"v.violation_id, v.installation_id, v.repository_id, v.rule_id, v.detector, v.title, "
|
|
134
|
+
"v.severity, v.severity_rank, v.action, v.commit_sha, v.author, v.status, "
|
|
135
|
+
"v.first_detected_at, v.last_detected_at, v.detections, v.acknowledged_at, "
|
|
136
|
+
"v.acknowledged_by_login, v.acknowledgement_note, v.resolved_at, v.resolution, "
|
|
137
|
+
"v.first_job_id, v.last_job_id, "
|
|
138
|
+
"(SELECT owner || '/' || name FROM known_repositories k WHERE "
|
|
139
|
+
"k.installation_id = v.installation_id AND k.repository_id = v.repository_id) AS full_name, "
|
|
140
|
+
"(SELECT account_id FROM installations i WHERE i.installation_id = v.installation_id) "
|
|
141
|
+
"AS account_id"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
SCAN_RESULTS: Mapping[str, ScanResultStatus] = {s.value: s for s in ScanResultStatus}
|
|
145
|
+
_RESULT_CLAUSES: Mapping[ScanResultStatus, str] = {
|
|
146
|
+
ScanResultStatus.QUEUED: "j.state = 'queued'",
|
|
147
|
+
ScanResultStatus.RUNNING: "j.state = 'running'",
|
|
148
|
+
ScanResultStatus.PASS: "j.state = 'passed' AND COALESCE(j.result_action, '') != 'warn'",
|
|
149
|
+
ScanResultStatus.WARNING: "j.state = 'passed' AND j.result_action = 'warn'",
|
|
150
|
+
ScanResultStatus.BLOCKED: "j.state = 'failed'",
|
|
151
|
+
ScanResultStatus.ERROR: "j.state = 'error'",
|
|
152
|
+
ScanResultStatus.CANCELLED: "j.state = 'cancelled' AND COALESCE(j.failure_kind, '') != 'stale'",
|
|
153
|
+
ScanResultStatus.STALE: "j.state = 'cancelled' AND j.failure_kind = 'stale'",
|
|
154
|
+
}
|
|
155
|
+
SCAN_SORTS = ("newest", "oldest")
|
|
156
|
+
VIOLATION_SORTS = ("newest", "oldest", "severity", "repository")
|
|
157
|
+
REPOSITORY_SORTS = ("name", "risk", "recent")
|
|
158
|
+
AUDIT_SORTS = ("newest", "oldest")
|
|
159
|
+
PERIODS: Mapping[str, timedelta] = {
|
|
160
|
+
"24h": timedelta(hours=24),
|
|
161
|
+
"7d": timedelta(days=7),
|
|
162
|
+
"30d": timedelta(days=30),
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _dt(value: float | None) -> datetime | None:
|
|
167
|
+
return None if value is None else datetime.fromtimestamp(value, UTC)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _req_dt(value: float) -> datetime:
|
|
171
|
+
return datetime.fromtimestamp(value, UTC)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class _Where:
|
|
175
|
+
"""Conditions built from constant fragments; values are always parameters."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, scope_clause: str, *scope_params: Any) -> None:
|
|
178
|
+
self.clauses = [scope_clause]
|
|
179
|
+
self.params: list[Any] = list(scope_params)
|
|
180
|
+
|
|
181
|
+
def add(self, clause: str, *params: Any) -> None:
|
|
182
|
+
self.clauses.append(clause)
|
|
183
|
+
self.params.extend(params)
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def sql(self) -> str:
|
|
187
|
+
return " AND ".join(f"({c})" for c in self.clauses)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(frozen=True, slots=True)
|
|
191
|
+
class ScanFilters:
|
|
192
|
+
organization_id: int | None = None
|
|
193
|
+
repository_id: int | None = None
|
|
194
|
+
result: ScanResultStatus | None = None
|
|
195
|
+
event: Literal["pull_request", "push"] | None = None
|
|
196
|
+
rule_id: str | None = None
|
|
197
|
+
severity: Severity | None = None
|
|
198
|
+
start: datetime | None = None
|
|
199
|
+
end: datetime | None = None
|
|
200
|
+
q: str | None = None
|
|
201
|
+
sort: str = "newest"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@dataclass(frozen=True, slots=True)
|
|
205
|
+
class ViolationFilters:
|
|
206
|
+
organization_id: int | None = None
|
|
207
|
+
repository_id: int | None = None
|
|
208
|
+
status: ViolationStatus | None = None
|
|
209
|
+
severity: Severity | None = None
|
|
210
|
+
rule_id: str | None = None
|
|
211
|
+
action: Action | None = None
|
|
212
|
+
start: datetime | None = None
|
|
213
|
+
end: datetime | None = None
|
|
214
|
+
q: str | None = None
|
|
215
|
+
sort: str = "newest"
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@dataclass(frozen=True, slots=True)
|
|
219
|
+
class RepositoryFilters:
|
|
220
|
+
organization_id: int | None = None
|
|
221
|
+
protection: ProtectionStatus | None = None
|
|
222
|
+
q: str | None = None
|
|
223
|
+
sort: str = "name"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
@dataclass(frozen=True, slots=True)
|
|
227
|
+
class AuditFilters:
|
|
228
|
+
organization_id: int | None = None
|
|
229
|
+
repository_id: int | None = None
|
|
230
|
+
event_type: AuditEventType | None = None
|
|
231
|
+
actor: str | None = None
|
|
232
|
+
start: datetime | None = None
|
|
233
|
+
end: datetime | None = None
|
|
234
|
+
sort: str = "newest"
|
|
235
|
+
rule_id: str | None = None # events about one rule (exceptions, violations)
|
|
236
|
+
exception_id: str | None = None
|
|
237
|
+
#: "organization" (organization policy events) or a draft, group or rollout ID.
|
|
238
|
+
policy: str | None = None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --------------------------------------------------------------------------- #
|
|
242
|
+
# Row conversion
|
|
243
|
+
# --------------------------------------------------------------------------- #
|
|
244
|
+
def scan_summary(row: Row) -> ScanSummary:
|
|
245
|
+
started = _dt(row["started_at"])
|
|
246
|
+
completed = _dt(row["completed_at"])
|
|
247
|
+
duration = (
|
|
248
|
+
int((completed - started).total_seconds() * 1000)
|
|
249
|
+
if started is not None and completed is not None and completed >= started
|
|
250
|
+
else None
|
|
251
|
+
)
|
|
252
|
+
return ScanSummary(
|
|
253
|
+
id=row["job_id"],
|
|
254
|
+
scan_id=row["scan_id"],
|
|
255
|
+
repository=RepositoryLink(
|
|
256
|
+
id=row["repository_id"],
|
|
257
|
+
installation_id=row["installation_id"],
|
|
258
|
+
full_name=f"{row['owner']}/{row['name']}",
|
|
259
|
+
),
|
|
260
|
+
organization_id=row["account_id"],
|
|
261
|
+
event=row["event"],
|
|
262
|
+
pull_request_number=row["pull_request_number"],
|
|
263
|
+
ref=row["ref"],
|
|
264
|
+
base_sha=row["base_sha"],
|
|
265
|
+
head_sha=row["head_sha"],
|
|
266
|
+
check_name=row["check_name"],
|
|
267
|
+
result=ScanResultStatus(
|
|
268
|
+
scan_result_label(row["state"], row["result_action"], row["failure_kind"])
|
|
269
|
+
),
|
|
270
|
+
commits_scanned=row["commits_scanned"],
|
|
271
|
+
violations=row["violations"],
|
|
272
|
+
warnings=row["warnings"],
|
|
273
|
+
findings=row["findings_count"],
|
|
274
|
+
created_at=_req_dt(row["created_at"]),
|
|
275
|
+
started_at=started,
|
|
276
|
+
completed_at=completed,
|
|
277
|
+
duration_ms=duration,
|
|
278
|
+
requested_by=row["requested_by"],
|
|
279
|
+
trigger=row["trigger_kind"] or row["event"],
|
|
280
|
+
execution=row["execution"] or 1,
|
|
281
|
+
failure_source=_failure_source(row["event"]),
|
|
282
|
+
)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _failure_source(event: str) -> Literal["pull_request", "push", "merge_queue"]:
|
|
286
|
+
if event == "merge_group":
|
|
287
|
+
return "merge_queue"
|
|
288
|
+
return "push" if event in ("push", "scheduled") else "pull_request"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _duration_ms(started: datetime | None, completed: datetime | None) -> int | None:
|
|
292
|
+
if started is None or completed is None or completed < started:
|
|
293
|
+
return None
|
|
294
|
+
return int((completed - started).total_seconds() * 1000)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def merge_group_view(record: MergeGroupRecord, job: Row | None) -> MergeGroupView:
|
|
298
|
+
return MergeGroupView(
|
|
299
|
+
head_sha=record.head_sha,
|
|
300
|
+
base_sha=record.base_sha,
|
|
301
|
+
base_ref=record.base_ref,
|
|
302
|
+
pull_requests=record.pull_requests,
|
|
303
|
+
state=record.state.value,
|
|
304
|
+
destroyed_reason=record.destroyed_reason,
|
|
305
|
+
result=ScanResultStatus(
|
|
306
|
+
scan_result_label(job["state"], job["result_action"], job["failure_kind"])
|
|
307
|
+
)
|
|
308
|
+
if job is not None
|
|
309
|
+
else None,
|
|
310
|
+
scan=job["job_id"] if job is not None else None,
|
|
311
|
+
created_at=record.created_at,
|
|
312
|
+
updated_at=record.updated_at,
|
|
313
|
+
validated_at=_dt(job["completed_at"]) if job is not None else None,
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _evidence(document: str) -> tuple[EvidenceView, ...]:
|
|
318
|
+
items = json.loads(document)
|
|
319
|
+
return tuple(
|
|
320
|
+
EvidenceView(
|
|
321
|
+
source=item["source"],
|
|
322
|
+
source_label=item["source_label"],
|
|
323
|
+
value=item["value"],
|
|
324
|
+
line_number=item.get("line_number"),
|
|
325
|
+
matched=tuple(MatchView(**m) for m in item.get("matched", [])),
|
|
326
|
+
notes=tuple(item.get("notes", [])),
|
|
327
|
+
)
|
|
328
|
+
for item in items
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def finding_view(row: Row) -> FindingView:
|
|
333
|
+
return FindingView(
|
|
334
|
+
id=row["finding_id"],
|
|
335
|
+
violation_id=row["violation_id"],
|
|
336
|
+
rule_id=row["rule_id"],
|
|
337
|
+
detector=row["detector"],
|
|
338
|
+
title=row["title"],
|
|
339
|
+
message=row["message"],
|
|
340
|
+
severity=Severity(row["severity"]),
|
|
341
|
+
confidence=row["confidence"],
|
|
342
|
+
action=Action(row["action"]),
|
|
343
|
+
reason=row["reason"],
|
|
344
|
+
commit_sha=row["commit_sha"],
|
|
345
|
+
author=row["author"],
|
|
346
|
+
committer=row["committer"],
|
|
347
|
+
evidence=_evidence(row["evidence"]),
|
|
348
|
+
remediation=row["remediation"],
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _violation_status(row: Row) -> ViolationStatus:
|
|
353
|
+
if row["status"] == "resolved":
|
|
354
|
+
return ViolationStatus.RESOLVED
|
|
355
|
+
if row["acknowledged_at"] is not None:
|
|
356
|
+
return ViolationStatus.ACKNOWLEDGED
|
|
357
|
+
return ViolationStatus.OPEN
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def violation_summary(row: Row) -> ViolationSummary:
|
|
361
|
+
return ViolationSummary(
|
|
362
|
+
id=row["violation_id"],
|
|
363
|
+
rule_id=row["rule_id"],
|
|
364
|
+
title=row["title"],
|
|
365
|
+
severity=Severity(row["severity"]),
|
|
366
|
+
action=Action(row["action"]),
|
|
367
|
+
repository=RepositoryLink(
|
|
368
|
+
id=row["repository_id"],
|
|
369
|
+
installation_id=row["installation_id"],
|
|
370
|
+
full_name=row["full_name"] or f"repository {row['repository_id']}",
|
|
371
|
+
),
|
|
372
|
+
organization_id=row["account_id"],
|
|
373
|
+
commit_sha=row["commit_sha"],
|
|
374
|
+
author=row["author"],
|
|
375
|
+
status=_violation_status(row),
|
|
376
|
+
first_detected_at=_req_dt(row["first_detected_at"]),
|
|
377
|
+
last_detected_at=_req_dt(row["last_detected_at"]),
|
|
378
|
+
detections=row["detections"],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _policies(document: str | None) -> tuple[PolicyEntry, ...]:
|
|
383
|
+
return tuple(PolicyEntry(**entry) for entry in json.loads(document or "[]"))
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def audit_summary(event: AuditEvent) -> str:
|
|
387
|
+
"""A one-line, server-side description of an audit event."""
|
|
388
|
+
data = event.data
|
|
389
|
+
t = AuditEventType
|
|
390
|
+
|
|
391
|
+
def value(key: str, default: str = "") -> str:
|
|
392
|
+
item = data.get(key)
|
|
393
|
+
return default if item is None else str(item)
|
|
394
|
+
|
|
395
|
+
texts: dict[AuditEventType, Callable[[], str]] = {
|
|
396
|
+
t.INSTALLATION_CREATED: lambda: "GitHub App installed",
|
|
397
|
+
t.INSTALLATION_REMOVED: lambda: "GitHub App uninstalled",
|
|
398
|
+
t.INSTALLATION_SUSPENDED: lambda: "GitHub App installation suspended",
|
|
399
|
+
t.INSTALLATION_UNSUSPENDED: lambda: "GitHub App installation unsuspended",
|
|
400
|
+
t.INSTALLATION_PERMISSIONS_UPDATED: lambda: "GitHub App permissions updated",
|
|
401
|
+
t.REPOSITORIES_ADDED: lambda: f"{value('repositories', '0')} repositories connected",
|
|
402
|
+
t.REPOSITORIES_REMOVED: lambda: f"{value('repositories', '0')} repositories disconnected",
|
|
403
|
+
t.REPOSITORIES_SYNCED: lambda: (
|
|
404
|
+
f"Repositories synchronized ({value('added', '0')} added, "
|
|
405
|
+
f"{value('removed', '0')} removed)"
|
|
406
|
+
),
|
|
407
|
+
t.WEBHOOK_REJECTED: lambda: f"Webhook rejected: {value('reason')}",
|
|
408
|
+
t.SCAN_QUEUED: lambda: "Scan queued",
|
|
409
|
+
t.SCAN_REQUESTED: lambda: "Re-scan requested",
|
|
410
|
+
t.REPOSITORY_SCANNED: lambda: f"Scanned {value('commits_scanned', '0')} commit(s)",
|
|
411
|
+
t.SCAN_PASSED: lambda: "Scan completed: passed",
|
|
412
|
+
t.SCAN_FAILED: lambda: f"Scan completed: blocked ({value('violations', '0')} violation(s))",
|
|
413
|
+
t.POLICY_VIOLATION: lambda: f"Policy violation: {value('rules')}",
|
|
414
|
+
t.POLICY_MODIFICATION: lambda: f"Security policy modification detected: {value('changes')}",
|
|
415
|
+
t.CONFIGURATION_ERROR: lambda: f"Configuration error: {value('reason')}",
|
|
416
|
+
t.SCAN_ERROR: lambda: f"Scan could not be completed: {value('reason')}",
|
|
417
|
+
t.SCAN_CANCELLED: lambda: f"Scan cancelled: {value('reason')}",
|
|
418
|
+
t.AUTHORIZATION_DENIED: lambda: f"Access denied: {value('reason')}",
|
|
419
|
+
t.PULL_REQUEST_MERGED: lambda: f"Pull request #{value('pull_request')} merged",
|
|
420
|
+
t.USER_SIGNED_IN: lambda: "Signed in",
|
|
421
|
+
t.USER_SIGNED_OUT: lambda: "Signed out",
|
|
422
|
+
t.SESSION_REVOKED: lambda: "Session revoked",
|
|
423
|
+
t.MEMBER_ROLE_GRANTED: lambda: f"Granted {value('new_role')} to user {value('member')}",
|
|
424
|
+
t.MEMBER_ROLE_CHANGED: lambda: (
|
|
425
|
+
f"Changed role of user {value('member')}: {value('old_role')} → {value('new_role')}"
|
|
426
|
+
),
|
|
427
|
+
t.MEMBER_REMOVED: lambda: f"Removed user {value('member')} ({value('old_role')})",
|
|
428
|
+
t.ORGANIZATION_POLICY_CHANGED: lambda: (
|
|
429
|
+
f"Changed organization policy v{value('old_version')} → v{value('new_version')}: "
|
|
430
|
+
f"{value('changes')}"
|
|
431
|
+
),
|
|
432
|
+
t.VIOLATION_OPENED: lambda: f"Violation detected: {value('rule')}",
|
|
433
|
+
t.VIOLATION_REOPENED: lambda: f"Violation reopened: {value('rule')}",
|
|
434
|
+
t.VIOLATION_RESOLVED: lambda: f"Violation resolved: {value('reason')}",
|
|
435
|
+
t.VIOLATION_ACKNOWLEDGED: lambda: f"Violation acknowledged: {value('rule')}",
|
|
436
|
+
t.VIOLATION_ACKNOWLEDGEMENT_REMOVED: lambda: f"Acknowledgement removed: {value('rule')}",
|
|
437
|
+
t.REPOSITORY_MONITORING_DISABLED: lambda: f"Monitoring paused: {value('reason')}",
|
|
438
|
+
t.REPOSITORY_MONITORING_ENABLED: lambda: "Monitoring resumed",
|
|
439
|
+
t.ENFORCEMENT_STATUS_CHECKED: lambda: (
|
|
440
|
+
f"Enforcement checked: Actions {value('actions')}, "
|
|
441
|
+
f"required check {value('branch_protection')}"
|
|
442
|
+
),
|
|
443
|
+
}
|
|
444
|
+
return texts.get(event.type, lambda: event.type.value.replace("_", " ").capitalize())()
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def audit_view(event: AuditEvent, full_names: Mapping[tuple[int, int], str]) -> AuditEventView:
|
|
448
|
+
repository = None
|
|
449
|
+
if event.repository_id is not None and event.installation_id is not None:
|
|
450
|
+
name = full_names.get((event.installation_id, event.repository_id)) or event.repository
|
|
451
|
+
repository = RepositoryLink(
|
|
452
|
+
id=event.repository_id,
|
|
453
|
+
installation_id=event.installation_id,
|
|
454
|
+
full_name=name or f"repository {event.repository_id}",
|
|
455
|
+
)
|
|
456
|
+
scan = event.data.get("job") if isinstance(event.data.get("job"), str) else event.job_id
|
|
457
|
+
return AuditEventView(
|
|
458
|
+
id=event.event_id,
|
|
459
|
+
type=event.type.value,
|
|
460
|
+
occurred_at=event.occurred_at,
|
|
461
|
+
actor=ActorView(type=event.actor_type.value, id=event.actor_id, login=event.actor_login),
|
|
462
|
+
organization_id=event.account_id,
|
|
463
|
+
installation_id=event.installation_id,
|
|
464
|
+
repository=repository,
|
|
465
|
+
head_sha=event.head_sha,
|
|
466
|
+
action=event.action,
|
|
467
|
+
summary=audit_summary(event),
|
|
468
|
+
data=dict(event.data),
|
|
469
|
+
scan=scan if isinstance(scan, str) else None,
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# --------------------------------------------------------------------------- #
|
|
474
|
+
# Protection
|
|
475
|
+
# --------------------------------------------------------------------------- #
|
|
476
|
+
def protection_for(
|
|
477
|
+
*,
|
|
478
|
+
app: AppConnection,
|
|
479
|
+
monitoring_enabled: bool,
|
|
480
|
+
latest_failure_kind: str | None,
|
|
481
|
+
branch_protection: str | None,
|
|
482
|
+
detail: str | None,
|
|
483
|
+
) -> tuple[ProtectionStatus, str]:
|
|
484
|
+
"""Explicit rules; a repository is never 'protected' only because the App is installed."""
|
|
485
|
+
if app is AppConnection.SUSPENDED:
|
|
486
|
+
return (
|
|
487
|
+
ProtectionStatus.AT_RISK,
|
|
488
|
+
"The GitHub App installation is suspended: CommitGuard checks no longer run.",
|
|
489
|
+
)
|
|
490
|
+
if app is AppConnection.DISCONNECTED:
|
|
491
|
+
return (
|
|
492
|
+
ProtectionStatus.AT_RISK,
|
|
493
|
+
"The GitHub App no longer has access to this repository: CommitGuard checks no "
|
|
494
|
+
"longer run.",
|
|
495
|
+
)
|
|
496
|
+
if not monitoring_enabled:
|
|
497
|
+
return ProtectionStatus.UNPROTECTED, "CommitGuard monitoring is paused for this repository."
|
|
498
|
+
if latest_failure_kind == "configuration":
|
|
499
|
+
return (
|
|
500
|
+
ProtectionStatus.CONFIGURATION_ERROR,
|
|
501
|
+
"The latest scan failed because the CommitGuard configuration is invalid.",
|
|
502
|
+
)
|
|
503
|
+
if branch_protection == RequiredCheckStatus.REQUIRED.value:
|
|
504
|
+
return ProtectionStatus.PROTECTED, detail or "A CommitGuard check is required."
|
|
505
|
+
if branch_protection == RequiredCheckStatus.NOT_REQUIRED.value:
|
|
506
|
+
return (
|
|
507
|
+
ProtectionStatus.UNPROTECTED,
|
|
508
|
+
(detail or "No CommitGuard check is required.")
|
|
509
|
+
+ " Failing checks do not block merges.",
|
|
510
|
+
)
|
|
511
|
+
return (
|
|
512
|
+
ProtectionStatus.UNKNOWN,
|
|
513
|
+
"Scanned by the GitHub App, but branch protection has not been verified.",
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
@dataclass(frozen=True, slots=True)
|
|
518
|
+
class _RepoRow:
|
|
519
|
+
row: Row
|
|
520
|
+
summary: RepositorySummary
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
class DashboardQueries:
|
|
524
|
+
def __init__(
|
|
525
|
+
self,
|
|
526
|
+
store: SqliteStateStore,
|
|
527
|
+
*,
|
|
528
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
529
|
+
) -> None:
|
|
530
|
+
self._store = store
|
|
531
|
+
self._now = now
|
|
532
|
+
|
|
533
|
+
# ------------------------------------------------------------------ #
|
|
534
|
+
# Scans
|
|
535
|
+
# ------------------------------------------------------------------ #
|
|
536
|
+
def list_scans(
|
|
537
|
+
self, scope: AccessScope, filters: ScanFilters, *, cursor: str | None, limit: int
|
|
538
|
+
) -> Page[ScanSummary]:
|
|
539
|
+
if not scope.installation_ids:
|
|
540
|
+
return Page([], None, limit)
|
|
541
|
+
where = _Where(SCOPE_JOBS, scope.installations_json, scope.session_hash)
|
|
542
|
+
self._scan_filters(where, filters)
|
|
543
|
+
position = decode_cursor(cursor, (int,))
|
|
544
|
+
newest = filters.sort != "oldest"
|
|
545
|
+
if position is not None:
|
|
546
|
+
where.add("j.sequence < ?" if newest else "j.sequence > ?", position[0])
|
|
547
|
+
order = "j.sequence DESC" if newest else "j.sequence ASC"
|
|
548
|
+
rows = self._store.query(
|
|
549
|
+
" ".join(
|
|
550
|
+
(
|
|
551
|
+
"SELECT",
|
|
552
|
+
_SCAN_COLUMNS,
|
|
553
|
+
"FROM scan_jobs j WHERE",
|
|
554
|
+
where.sql,
|
|
555
|
+
"ORDER BY",
|
|
556
|
+
order,
|
|
557
|
+
"LIMIT ?",
|
|
558
|
+
)
|
|
559
|
+
),
|
|
560
|
+
(*where.params, limit + 1),
|
|
561
|
+
)
|
|
562
|
+
items = [scan_summary(r) for r in rows[:limit]]
|
|
563
|
+
next_cursor = encode_cursor([rows[limit - 1]["sequence"]]) if len(rows) > limit else None
|
|
564
|
+
return Page(items, next_cursor, limit)
|
|
565
|
+
|
|
566
|
+
@staticmethod
|
|
567
|
+
def _scan_filters(where: _Where, filters: ScanFilters) -> None:
|
|
568
|
+
if filters.organization_id is not None:
|
|
569
|
+
where.add(
|
|
570
|
+
"j.installation_id IN (SELECT installation_id FROM installations "
|
|
571
|
+
"WHERE account_id = ?)",
|
|
572
|
+
filters.organization_id,
|
|
573
|
+
)
|
|
574
|
+
if filters.repository_id is not None:
|
|
575
|
+
where.add("j.repository_id = ?", filters.repository_id)
|
|
576
|
+
if filters.result is not None:
|
|
577
|
+
where.add(_RESULT_CLAUSES[filters.result])
|
|
578
|
+
if filters.event is not None:
|
|
579
|
+
where.add("j.event = ?", filters.event)
|
|
580
|
+
if filters.rule_id is not None:
|
|
581
|
+
where.add(
|
|
582
|
+
"EXISTS (SELECT 1 FROM findings f WHERE f.job_id = j.job_id AND f.rule_id = ?)",
|
|
583
|
+
filters.rule_id,
|
|
584
|
+
)
|
|
585
|
+
if filters.severity is not None:
|
|
586
|
+
where.add(
|
|
587
|
+
"EXISTS (SELECT 1 FROM findings f WHERE f.job_id = j.job_id AND f.severity = ?)",
|
|
588
|
+
filters.severity.value,
|
|
589
|
+
)
|
|
590
|
+
if filters.start is not None:
|
|
591
|
+
where.add("j.created_at >= ?", filters.start.timestamp())
|
|
592
|
+
if filters.end is not None:
|
|
593
|
+
where.add("j.created_at < ?", filters.end.timestamp())
|
|
594
|
+
if filters.q is not None:
|
|
595
|
+
prefix = sha_prefix(filters.q)
|
|
596
|
+
if prefix is not None:
|
|
597
|
+
where.add(
|
|
598
|
+
"(j.head_sha LIKE ? OR (j.owner || '/' || j.name) LIKE ? ESCAPE '\\')",
|
|
599
|
+
prefix + "%",
|
|
600
|
+
like_pattern(filters.q),
|
|
601
|
+
)
|
|
602
|
+
else:
|
|
603
|
+
where.add("(j.owner || '/' || j.name) LIKE ? ESCAPE '\\'", like_pattern(filters.q))
|
|
604
|
+
|
|
605
|
+
def _scan_row(self, scope: AccessScope, scan_id: str) -> Row | None:
|
|
606
|
+
if not scope.installation_ids or not _is_hex_id(scan_id):
|
|
607
|
+
return None
|
|
608
|
+
rows = self._store.query(
|
|
609
|
+
" ".join(
|
|
610
|
+
("SELECT", _SCAN_COLUMNS, "FROM scan_jobs j WHERE j.job_id = ? AND", SCOPE_JOBS)
|
|
611
|
+
),
|
|
612
|
+
(scan_id, scope.installations_json, scope.session_hash),
|
|
613
|
+
)
|
|
614
|
+
return rows[0] if rows else None
|
|
615
|
+
|
|
616
|
+
def get_scan(
|
|
617
|
+
self, scope: AccessScope, scan_id: str, *, principal: Principal
|
|
618
|
+
) -> ScanDetail | None:
|
|
619
|
+
row = self._scan_row(scope, scan_id)
|
|
620
|
+
if row is None:
|
|
621
|
+
return None
|
|
622
|
+
findings = self._store.query(
|
|
623
|
+
"SELECT * FROM findings WHERE job_id = ? ORDER BY severity_rank DESC, finding_id",
|
|
624
|
+
(scan_id,),
|
|
625
|
+
)
|
|
626
|
+
summary = scan_summary(row)
|
|
627
|
+
can_rescan, blocked_reason = self.rescan_eligibility(row, principal)
|
|
628
|
+
failure = None
|
|
629
|
+
if row["state"] in ("error", "cancelled"):
|
|
630
|
+
failure = ScanFailure(kind=row["failure_kind"], message=row["message"] or "")
|
|
631
|
+
executions = self._store.query(
|
|
632
|
+
"SELECT COUNT(*) AS n, (SELECT job_id FROM scan_jobs o WHERE o.installation_id = ? "
|
|
633
|
+
"AND o.repository_id = ? AND o.scan_key = ? ORDER BY o.sequence DESC LIMIT 1) "
|
|
634
|
+
"AS latest FROM scan_jobs WHERE installation_id = ? AND repository_id = ? "
|
|
635
|
+
"AND scan_key = ?",
|
|
636
|
+
(
|
|
637
|
+
row["installation_id"],
|
|
638
|
+
row["repository_id"],
|
|
639
|
+
row["scan_key"],
|
|
640
|
+
row["installation_id"],
|
|
641
|
+
row["repository_id"],
|
|
642
|
+
row["scan_key"],
|
|
643
|
+
),
|
|
644
|
+
)[0]
|
|
645
|
+
merge_group = None
|
|
646
|
+
if row["event"] == "merge_group":
|
|
647
|
+
record = self._store.get_merge_group(
|
|
648
|
+
row["installation_id"], row["repository_id"], row["head_sha"]
|
|
649
|
+
)
|
|
650
|
+
if record is not None:
|
|
651
|
+
merge_group = merge_group_view(record, row)
|
|
652
|
+
return ScanDetail(
|
|
653
|
+
executions=int(executions["n"] or 1),
|
|
654
|
+
latest_execution=executions["latest"] or row["job_id"],
|
|
655
|
+
merge_group=merge_group,
|
|
656
|
+
scan=summary,
|
|
657
|
+
conclusion=row["conclusion"],
|
|
658
|
+
tool_version=row["tool_version"],
|
|
659
|
+
rules_version=row["rules_version"],
|
|
660
|
+
policy_version=row["policy_version"],
|
|
661
|
+
policy_source=row["policy_source"],
|
|
662
|
+
organization_policy_version=row["organization_policy_version"],
|
|
663
|
+
effective_policies=_policies(row["effective_policies"]),
|
|
664
|
+
detector_failures=row["detector_failures"],
|
|
665
|
+
notices=tuple(json.loads(row["notices"] or "[]")),
|
|
666
|
+
failure=failure,
|
|
667
|
+
findings=tuple(finding_view(f) for f in findings),
|
|
668
|
+
can_rescan=can_rescan,
|
|
669
|
+
rescan_blocked_reason=blocked_reason,
|
|
670
|
+
)
|
|
671
|
+
|
|
672
|
+
def scan_executions(self, scope: AccessScope, scan_id: str) -> ExecutionHistory | None:
|
|
673
|
+
"""Every execution of the logical scan ``scan_id`` belongs to, newest first."""
|
|
674
|
+
row = self._scan_row(scope, scan_id)
|
|
675
|
+
if row is None:
|
|
676
|
+
return None
|
|
677
|
+
rows = self._store.query(
|
|
678
|
+
" ".join(
|
|
679
|
+
(
|
|
680
|
+
"SELECT",
|
|
681
|
+
_SCAN_COLUMNS,
|
|
682
|
+
"FROM scan_jobs j WHERE j.installation_id = ? AND j.repository_id = ? "
|
|
683
|
+
"AND j.scan_key = ? ORDER BY j.sequence DESC LIMIT 100",
|
|
684
|
+
)
|
|
685
|
+
),
|
|
686
|
+
(row["installation_id"], row["repository_id"], row["scan_key"]),
|
|
687
|
+
)
|
|
688
|
+
items = []
|
|
689
|
+
for index, r in enumerate(rows):
|
|
690
|
+
started, completed = _dt(r["started_at"]), _dt(r["completed_at"])
|
|
691
|
+
failure = (
|
|
692
|
+
ScanFailure(kind=r["failure_kind"], message=r["message"] or "")
|
|
693
|
+
if r["state"] in ("error", "cancelled")
|
|
694
|
+
else None
|
|
695
|
+
)
|
|
696
|
+
items.append(
|
|
697
|
+
ExecutionView(
|
|
698
|
+
id=r["job_id"],
|
|
699
|
+
execution=r["execution"] or 1,
|
|
700
|
+
trigger=r["trigger_kind"] or r["event"],
|
|
701
|
+
current=index == 0,
|
|
702
|
+
result=ScanResultStatus(
|
|
703
|
+
scan_result_label(r["state"], r["result_action"], r["failure_kind"])
|
|
704
|
+
),
|
|
705
|
+
head_sha=r["head_sha"],
|
|
706
|
+
base_sha=r["base_sha"],
|
|
707
|
+
organization_policy_version=r["organization_policy_version"],
|
|
708
|
+
policy_version=r["policy_version"],
|
|
709
|
+
rules_version=r["rules_version"],
|
|
710
|
+
tool_version=r["tool_version"],
|
|
711
|
+
conclusion=r["conclusion"],
|
|
712
|
+
requested_by=r["requested_by"],
|
|
713
|
+
failure=failure,
|
|
714
|
+
created_at=_req_dt(r["created_at"]),
|
|
715
|
+
started_at=started,
|
|
716
|
+
completed_at=completed,
|
|
717
|
+
duration_ms=_duration_ms(started, completed),
|
|
718
|
+
)
|
|
719
|
+
)
|
|
720
|
+
evaluated = [i for i in items if i.policy_version is not None]
|
|
721
|
+
return ExecutionHistory(
|
|
722
|
+
scan_id=scan_id,
|
|
723
|
+
items=tuple(items),
|
|
724
|
+
policy_changed=len(
|
|
725
|
+
{(i.policy_version, i.organization_policy_version) for i in evaluated}
|
|
726
|
+
)
|
|
727
|
+
> 1,
|
|
728
|
+
rules_changed=len({i.rules_version for i in evaluated}) > 1,
|
|
729
|
+
)
|
|
730
|
+
|
|
731
|
+
def rescan_eligibility(self, row: Row, principal: Principal) -> tuple[bool, str | None]:
|
|
732
|
+
account_id = row["account_id"]
|
|
733
|
+
if account_id is None or not principal.can(Permission.SCANS_TRIGGER, int(account_id)):
|
|
734
|
+
return False, "Your role cannot request scans."
|
|
735
|
+
if row["state"] in ("queued", "running"):
|
|
736
|
+
return False, "This scan has not finished yet."
|
|
737
|
+
latest = self._store.latest_group_sequence(
|
|
738
|
+
row["installation_id"], row["repository_id"], row["group_key"]
|
|
739
|
+
)
|
|
740
|
+
if latest > row["sequence"]:
|
|
741
|
+
return False, "A newer scan exists for this pull request or branch."
|
|
742
|
+
installation = self._store.get_installation(row["installation_id"])
|
|
743
|
+
if installation is None or installation.state.value != "active":
|
|
744
|
+
return False, "The GitHub App installation is not active."
|
|
745
|
+
if not self._store.repository_listed(row["installation_id"], row["repository_id"]):
|
|
746
|
+
return False, "The GitHub App no longer has access to this repository."
|
|
747
|
+
if not self._store.monitoring_enabled(row["installation_id"], row["repository_id"]):
|
|
748
|
+
return False, "Monitoring is paused for this repository."
|
|
749
|
+
return True, None
|
|
750
|
+
|
|
751
|
+
def scan_row_for_command(self, scope: AccessScope, scan_id: str) -> Row | None:
|
|
752
|
+
return self._scan_row(scope, scan_id)
|
|
753
|
+
|
|
754
|
+
def compare_scan(self, scope: AccessScope, scan_id: str) -> ScanComparison | None:
|
|
755
|
+
row = self._scan_row(scope, scan_id)
|
|
756
|
+
if row is None:
|
|
757
|
+
return None
|
|
758
|
+
previous = self._store.query(
|
|
759
|
+
"SELECT job_id FROM scan_jobs WHERE installation_id = ? AND repository_id = ? "
|
|
760
|
+
"AND group_key = ? AND sequence < ? AND state IN ('passed', 'failed') "
|
|
761
|
+
"ORDER BY sequence DESC LIMIT 1",
|
|
762
|
+
(row["installation_id"], row["repository_id"], row["group_key"], row["sequence"]),
|
|
763
|
+
)
|
|
764
|
+
current = self._findings_by_fingerprint(scan_id)
|
|
765
|
+
before = self._findings_by_fingerprint(previous[0]["job_id"]) if previous else {}
|
|
766
|
+
new = sorted(set(current) - set(before))
|
|
767
|
+
resolved = sorted(set(before) - set(current))
|
|
768
|
+
unchanged = sorted(set(current) & set(before))
|
|
769
|
+
return ScanComparison(
|
|
770
|
+
scan_id=scan_id,
|
|
771
|
+
previous_scan_id=previous[0]["job_id"] if previous else None,
|
|
772
|
+
new=tuple(new),
|
|
773
|
+
resolved=tuple(resolved),
|
|
774
|
+
unchanged=tuple(unchanged),
|
|
775
|
+
new_findings=tuple(finding_view(current[f]) for f in new),
|
|
776
|
+
resolved_findings=tuple(finding_view(before[f]) for f in resolved),
|
|
777
|
+
)
|
|
778
|
+
|
|
779
|
+
def _findings_by_fingerprint(self, job_id: str) -> dict[str, Row]:
|
|
780
|
+
rows = self._store.query(
|
|
781
|
+
"SELECT * FROM findings WHERE job_id = ? ORDER BY finding_id LIMIT 5000", (job_id,)
|
|
782
|
+
)
|
|
783
|
+
return {r["fingerprint"]: r for r in rows}
|
|
784
|
+
|
|
785
|
+
# ------------------------------------------------------------------ #
|
|
786
|
+
# Violations
|
|
787
|
+
# ------------------------------------------------------------------ #
|
|
788
|
+
def list_violations(
|
|
789
|
+
self, scope: AccessScope, filters: ViolationFilters, *, cursor: str | None, limit: int
|
|
790
|
+
) -> Page[ViolationSummary]:
|
|
791
|
+
if not scope.installation_ids:
|
|
792
|
+
return Page([], None, limit)
|
|
793
|
+
where = _Where(SCOPE_VIOLATIONS, scope.installations_json, scope.session_hash)
|
|
794
|
+
if filters.organization_id is not None:
|
|
795
|
+
where.add(
|
|
796
|
+
"v.installation_id IN (SELECT installation_id FROM installations "
|
|
797
|
+
"WHERE account_id = ?)",
|
|
798
|
+
filters.organization_id,
|
|
799
|
+
)
|
|
800
|
+
if filters.repository_id is not None:
|
|
801
|
+
where.add("v.repository_id = ?", filters.repository_id)
|
|
802
|
+
if filters.status is ViolationStatus.RESOLVED:
|
|
803
|
+
where.add("v.status = 'resolved'")
|
|
804
|
+
elif filters.status is ViolationStatus.ACKNOWLEDGED:
|
|
805
|
+
where.add("v.status = 'open' AND v.acknowledged_at IS NOT NULL")
|
|
806
|
+
elif filters.status is ViolationStatus.OPEN:
|
|
807
|
+
where.add("v.status = 'open' AND v.acknowledged_at IS NULL")
|
|
808
|
+
if filters.severity is not None:
|
|
809
|
+
where.add("v.severity = ?", filters.severity.value)
|
|
810
|
+
if filters.rule_id is not None:
|
|
811
|
+
where.add("v.rule_id = ?", filters.rule_id)
|
|
812
|
+
if filters.action is not None:
|
|
813
|
+
where.add("v.action = ?", filters.action.value)
|
|
814
|
+
if filters.start is not None:
|
|
815
|
+
where.add("v.last_detected_at >= ?", filters.start.timestamp())
|
|
816
|
+
if filters.end is not None:
|
|
817
|
+
where.add("v.last_detected_at < ?", filters.end.timestamp())
|
|
818
|
+
if filters.q is not None:
|
|
819
|
+
prefix = sha_prefix(filters.q)
|
|
820
|
+
pattern = like_pattern(filters.q)
|
|
821
|
+
where.add(
|
|
822
|
+
"(v.rule_id LIKE ? ESCAPE '\\' OR v.author LIKE ? ESCAPE '\\' OR "
|
|
823
|
+
"COALESCE(v.commit_sha, '') LIKE ? OR EXISTS (SELECT 1 FROM known_repositories k "
|
|
824
|
+
"WHERE k.installation_id = v.installation_id AND k.repository_id = v.repository_id "
|
|
825
|
+
"AND (k.owner || '/' || k.name) LIKE ? ESCAPE '\\'))",
|
|
826
|
+
pattern,
|
|
827
|
+
pattern,
|
|
828
|
+
(prefix + "%") if prefix else "\x00",
|
|
829
|
+
pattern,
|
|
830
|
+
)
|
|
831
|
+
sort = filters.sort if filters.sort in VIOLATION_SORTS else "newest"
|
|
832
|
+
if sort == "severity":
|
|
833
|
+
position = decode_cursor(cursor, (int, float, str))
|
|
834
|
+
if position is not None:
|
|
835
|
+
where.add(
|
|
836
|
+
"(v.severity_rank < ? OR (v.severity_rank = ? AND (v.last_detected_at < ? OR "
|
|
837
|
+
"(v.last_detected_at = ? AND v.violation_id < ?))))",
|
|
838
|
+
position[0],
|
|
839
|
+
position[0],
|
|
840
|
+
position[1],
|
|
841
|
+
position[1],
|
|
842
|
+
position[2],
|
|
843
|
+
)
|
|
844
|
+
order = "v.severity_rank DESC, v.last_detected_at DESC, v.violation_id DESC"
|
|
845
|
+
elif sort == "repository":
|
|
846
|
+
position = decode_cursor(cursor, (str, float, str))
|
|
847
|
+
name_expr = (
|
|
848
|
+
"COALESCE((SELECT owner || '/' || name FROM known_repositories k WHERE "
|
|
849
|
+
"k.installation_id = v.installation_id AND k.repository_id = v.repository_id), '')"
|
|
850
|
+
)
|
|
851
|
+
if position is not None:
|
|
852
|
+
where.add(
|
|
853
|
+
" ".join(
|
|
854
|
+
(
|
|
855
|
+
"(",
|
|
856
|
+
name_expr,
|
|
857
|
+
"> ? OR (",
|
|
858
|
+
name_expr,
|
|
859
|
+
"= ? AND (v.last_detected_at < ? "
|
|
860
|
+
"OR (v.last_detected_at = ? AND v.violation_id < ?))))",
|
|
861
|
+
)
|
|
862
|
+
),
|
|
863
|
+
position[0],
|
|
864
|
+
position[0],
|
|
865
|
+
position[1],
|
|
866
|
+
position[1],
|
|
867
|
+
position[2],
|
|
868
|
+
)
|
|
869
|
+
order = " ".join((name_expr, "ASC, v.last_detected_at DESC, v.violation_id DESC"))
|
|
870
|
+
else:
|
|
871
|
+
newest = sort == "newest"
|
|
872
|
+
position = decode_cursor(cursor, (float, str))
|
|
873
|
+
if position is not None:
|
|
874
|
+
where.add(
|
|
875
|
+
"(v.last_detected_at < ? OR (v.last_detected_at = ? AND v.violation_id < ?))"
|
|
876
|
+
if newest
|
|
877
|
+
else (
|
|
878
|
+
"(v.last_detected_at > ? OR (v.last_detected_at = ? "
|
|
879
|
+
"AND v.violation_id > ?))"
|
|
880
|
+
),
|
|
881
|
+
position[0],
|
|
882
|
+
position[0],
|
|
883
|
+
position[1],
|
|
884
|
+
)
|
|
885
|
+
order = (
|
|
886
|
+
"v.last_detected_at DESC, v.violation_id DESC"
|
|
887
|
+
if newest
|
|
888
|
+
else "v.last_detected_at ASC, v.violation_id ASC"
|
|
889
|
+
)
|
|
890
|
+
rows = self._store.query(
|
|
891
|
+
" ".join(
|
|
892
|
+
(
|
|
893
|
+
"SELECT",
|
|
894
|
+
_VIOLATION_COLUMNS,
|
|
895
|
+
"FROM violations v WHERE",
|
|
896
|
+
where.sql,
|
|
897
|
+
"ORDER BY",
|
|
898
|
+
order,
|
|
899
|
+
"LIMIT ?",
|
|
900
|
+
)
|
|
901
|
+
),
|
|
902
|
+
(*where.params, limit + 1),
|
|
903
|
+
)
|
|
904
|
+
items = [violation_summary(r) for r in rows[:limit]]
|
|
905
|
+
next_cursor = None
|
|
906
|
+
if len(rows) > limit:
|
|
907
|
+
last = rows[limit - 1]
|
|
908
|
+
if sort == "severity":
|
|
909
|
+
next_cursor = encode_cursor(
|
|
910
|
+
[last["severity_rank"], last["last_detected_at"], last["violation_id"]]
|
|
911
|
+
)
|
|
912
|
+
elif sort == "repository":
|
|
913
|
+
next_cursor = encode_cursor(
|
|
914
|
+
[last["full_name"] or "", last["last_detected_at"], last["violation_id"]]
|
|
915
|
+
)
|
|
916
|
+
else:
|
|
917
|
+
next_cursor = encode_cursor([last["last_detected_at"], last["violation_id"]])
|
|
918
|
+
return Page(items, next_cursor, limit)
|
|
919
|
+
|
|
920
|
+
def violation_row(self, scope: AccessScope, violation_id: str) -> Row | None:
|
|
921
|
+
if not scope.installation_ids or not _is_hex_id(violation_id):
|
|
922
|
+
return None
|
|
923
|
+
rows = self._store.query(
|
|
924
|
+
" ".join(
|
|
925
|
+
(
|
|
926
|
+
"SELECT",
|
|
927
|
+
_VIOLATION_COLUMNS,
|
|
928
|
+
"FROM violations v WHERE v.violation_id = ? AND",
|
|
929
|
+
SCOPE_VIOLATIONS,
|
|
930
|
+
)
|
|
931
|
+
),
|
|
932
|
+
(violation_id, scope.installations_json, scope.session_hash),
|
|
933
|
+
)
|
|
934
|
+
return rows[0] if rows else None
|
|
935
|
+
|
|
936
|
+
def get_violation(
|
|
937
|
+
self, scope: AccessScope, violation_id: str, *, principal: Principal
|
|
938
|
+
) -> ViolationDetail | None:
|
|
939
|
+
row = self.violation_row(scope, violation_id)
|
|
940
|
+
if row is None:
|
|
941
|
+
return None
|
|
942
|
+
latest = self._store.query(
|
|
943
|
+
"SELECT * FROM findings WHERE violation_id = ? ORDER BY finding_id DESC LIMIT 1",
|
|
944
|
+
(violation_id,),
|
|
945
|
+
)
|
|
946
|
+
exposures = self._store.query(
|
|
947
|
+
"SELECT kind, label, active, opened_at, closed_at, closed_reason FROM "
|
|
948
|
+
"violation_exposures WHERE violation_id = ? ORDER BY active DESC, opened_at DESC "
|
|
949
|
+
"LIMIT 100",
|
|
950
|
+
(violation_id,),
|
|
951
|
+
)
|
|
952
|
+
detections = self._store.query(
|
|
953
|
+
"SELECT f.job_id, f.action, f.created_at, j.state, j.result_action, j.head_sha "
|
|
954
|
+
"FROM findings f JOIN scan_jobs j ON j.job_id = f.job_id WHERE f.violation_id = ? "
|
|
955
|
+
"ORDER BY f.finding_id DESC LIMIT 50",
|
|
956
|
+
(violation_id,),
|
|
957
|
+
)
|
|
958
|
+
finding = latest[0] if latest else None
|
|
959
|
+
acknowledgement = None
|
|
960
|
+
if row["acknowledged_at"] is not None:
|
|
961
|
+
acknowledgement = AcknowledgementView(
|
|
962
|
+
by=row["acknowledged_by_login"],
|
|
963
|
+
at=_req_dt(row["acknowledged_at"]),
|
|
964
|
+
note=row["acknowledgement_note"],
|
|
965
|
+
)
|
|
966
|
+
account_id = row["account_id"]
|
|
967
|
+
return ViolationDetail(
|
|
968
|
+
violation=violation_summary(row),
|
|
969
|
+
detector=row["detector"],
|
|
970
|
+
message=finding["message"] if finding else row["title"],
|
|
971
|
+
committer=finding["committer"] if finding else None,
|
|
972
|
+
policy_reason=finding["reason"] if finding else "",
|
|
973
|
+
evidence=_evidence(finding["evidence"]) if finding else (),
|
|
974
|
+
remediation=finding["remediation"] if finding else "",
|
|
975
|
+
recommended_steps=remediation_steps(row["rule_id"]),
|
|
976
|
+
resolved_at=_dt(row["resolved_at"]),
|
|
977
|
+
resolution=row["resolution"],
|
|
978
|
+
acknowledgement=acknowledgement,
|
|
979
|
+
exposures=tuple(
|
|
980
|
+
ExposureView(
|
|
981
|
+
kind=e["kind"],
|
|
982
|
+
label=e["label"],
|
|
983
|
+
active=bool(e["active"]),
|
|
984
|
+
opened_at=_req_dt(e["opened_at"]),
|
|
985
|
+
closed_at=_dt(e["closed_at"]),
|
|
986
|
+
closed_reason=e["closed_reason"],
|
|
987
|
+
)
|
|
988
|
+
for e in exposures
|
|
989
|
+
),
|
|
990
|
+
detections=tuple(
|
|
991
|
+
DetectionView(
|
|
992
|
+
scan=d["job_id"],
|
|
993
|
+
result=ScanResultStatus(scan_result_label(d["state"], d["result_action"])),
|
|
994
|
+
detected_at=_req_dt(d["created_at"]),
|
|
995
|
+
action=Action(d["action"]),
|
|
996
|
+
head_sha=d["head_sha"],
|
|
997
|
+
)
|
|
998
|
+
for d in detections
|
|
999
|
+
),
|
|
1000
|
+
first_scan_id=row["first_job_id"],
|
|
1001
|
+
last_scan_id=row["last_job_id"],
|
|
1002
|
+
can_manage=account_id is not None
|
|
1003
|
+
and principal.can(Permission.VIOLATIONS_MANAGE, int(account_id))
|
|
1004
|
+
and row["status"] == "open",
|
|
1005
|
+
)
|
|
1006
|
+
|
|
1007
|
+
# ------------------------------------------------------------------ #
|
|
1008
|
+
# Repositories
|
|
1009
|
+
# ------------------------------------------------------------------ #
|
|
1010
|
+
def _repository_rows(
|
|
1011
|
+
self,
|
|
1012
|
+
scope: AccessScope,
|
|
1013
|
+
*,
|
|
1014
|
+
organization_id: int | None = None,
|
|
1015
|
+
repository_id: int | None = None,
|
|
1016
|
+
q: str | None = None,
|
|
1017
|
+
) -> list[_RepoRow]:
|
|
1018
|
+
if not scope.installation_ids:
|
|
1019
|
+
return []
|
|
1020
|
+
where = _Where(SCOPE_REPOSITORIES, scope.installations_json, scope.session_hash)
|
|
1021
|
+
if organization_id is not None:
|
|
1022
|
+
where.add("i.account_id = ?", organization_id)
|
|
1023
|
+
if repository_id is not None:
|
|
1024
|
+
where.add("r.repository_id = ?", repository_id)
|
|
1025
|
+
if q is not None:
|
|
1026
|
+
where.add("(r.owner || '/' || r.name) LIKE ? ESCAPE '\\'", like_pattern(q))
|
|
1027
|
+
sql = " ".join(
|
|
1028
|
+
(
|
|
1029
|
+
"SELECT r.installation_id, r.repository_id, r.owner, r.name, r.default_branch, "
|
|
1030
|
+
"r.removed_at, i.account_id, i.account_login, i.account_type, i.state AS "
|
|
1031
|
+
"installation_state, EXISTS (SELECT 1 FROM installation_repositories ir WHERE "
|
|
1032
|
+
"ir.installation_id = r.installation_id AND ir.repository_id = r.repository_id) "
|
|
1033
|
+
"AS listed, COALESCE((SELECT monitoring_enabled FROM repository_settings s WHERE "
|
|
1034
|
+
"s.installation_id = r.installation_id AND s.repository_id = r.repository_id), 1) "
|
|
1035
|
+
"AS monitoring_enabled, (SELECT MAX(sequence) FROM scan_jobs j WHERE "
|
|
1036
|
+
"j.installation_id = r.installation_id AND j.repository_id = r.repository_id) "
|
|
1037
|
+
"AS latest_sequence, (SELECT failure_kind FROM scan_jobs j WHERE "
|
|
1038
|
+
"j.installation_id = r.installation_id AND j.repository_id = r.repository_id AND "
|
|
1039
|
+
"j.state IN ('passed', 'failed', 'error') ORDER BY sequence DESC LIMIT 1) AS "
|
|
1040
|
+
"latest_failure_kind, (SELECT COUNT(*) FROM violations v WHERE "
|
|
1041
|
+
"v.installation_id = r.installation_id AND v.repository_id = r.repository_id AND "
|
|
1042
|
+
"v.status = 'open' AND v.action = 'block') AS open_violations, (SELECT COUNT(*) "
|
|
1043
|
+
"FROM violations v WHERE v.installation_id = r.installation_id AND "
|
|
1044
|
+
"v.repository_id = r.repository_id AND v.status = 'open' AND v.action = 'warn') "
|
|
1045
|
+
"AS open_warnings, (SELECT COUNT(*) FROM violations v WHERE "
|
|
1046
|
+
"v.installation_id = r.installation_id AND v.repository_id = r.repository_id AND "
|
|
1047
|
+
"v.status = 'open' AND v.severity = 'critical') AS critical_open, "
|
|
1048
|
+
"e.branch_protection, e.branch_protection_detail, e.required_checks, e.branch, "
|
|
1049
|
+
"e.actions, e.actions_detail, e.checked_at, e.merge_queue, e.merge_queue_detail "
|
|
1050
|
+
"FROM known_repositories r JOIN installations i ON i.installation_id = "
|
|
1051
|
+
"r.installation_id LEFT JOIN enforcement_status e ON e.installation_id = "
|
|
1052
|
+
"r.installation_id AND e.repository_id = r.repository_id WHERE",
|
|
1053
|
+
where.sql,
|
|
1054
|
+
"LIMIT 20000",
|
|
1055
|
+
)
|
|
1056
|
+
)
|
|
1057
|
+
rows = self._store.query(sql, where.params)
|
|
1058
|
+
sequences = [r["latest_sequence"] for r in rows if r["latest_sequence"] is not None]
|
|
1059
|
+
latest: dict[int, Row] = {}
|
|
1060
|
+
for chunk_start in range(0, len(sequences), 500):
|
|
1061
|
+
chunk = sequences[chunk_start : chunk_start + 500]
|
|
1062
|
+
for scan in self._store.query(
|
|
1063
|
+
" ".join(
|
|
1064
|
+
(
|
|
1065
|
+
"SELECT",
|
|
1066
|
+
_SCAN_COLUMNS,
|
|
1067
|
+
"FROM scan_jobs j WHERE j.sequence IN (SELECT value FROM json_each(?))",
|
|
1068
|
+
)
|
|
1069
|
+
),
|
|
1070
|
+
(json.dumps(chunk),),
|
|
1071
|
+
):
|
|
1072
|
+
latest[int(scan["sequence"])] = scan
|
|
1073
|
+
results = []
|
|
1074
|
+
for row in rows:
|
|
1075
|
+
scan_row = latest.get(row["latest_sequence"]) if row["latest_sequence"] else None
|
|
1076
|
+
results.append(_RepoRow(row, self._repository_summary(row, scan_row)))
|
|
1077
|
+
return results
|
|
1078
|
+
|
|
1079
|
+
@staticmethod
|
|
1080
|
+
def _app_connection(row: Row) -> AppConnection:
|
|
1081
|
+
if row["installation_state"] == "suspended":
|
|
1082
|
+
return AppConnection.SUSPENDED
|
|
1083
|
+
if row["installation_state"] != "active" or not row["listed"]:
|
|
1084
|
+
return AppConnection.DISCONNECTED
|
|
1085
|
+
return AppConnection.CONNECTED
|
|
1086
|
+
|
|
1087
|
+
def _repository_summary(self, row: Row, scan_row: Row | None) -> RepositorySummary:
|
|
1088
|
+
app = self._app_connection(row)
|
|
1089
|
+
monitoring = bool(row["monitoring_enabled"])
|
|
1090
|
+
protection, reason = protection_for(
|
|
1091
|
+
app=app,
|
|
1092
|
+
monitoring_enabled=monitoring,
|
|
1093
|
+
latest_failure_kind=row["latest_failure_kind"],
|
|
1094
|
+
branch_protection=row["branch_protection"],
|
|
1095
|
+
detail=row["branch_protection_detail"],
|
|
1096
|
+
)
|
|
1097
|
+
owner, name = row["owner"], row["name"]
|
|
1098
|
+
return RepositorySummary(
|
|
1099
|
+
id=row["repository_id"],
|
|
1100
|
+
installation_id=row["installation_id"],
|
|
1101
|
+
organization=OrganizationRef(
|
|
1102
|
+
id=row["account_id"], login=row["account_login"], type=row["account_type"]
|
|
1103
|
+
),
|
|
1104
|
+
owner=owner,
|
|
1105
|
+
name=name,
|
|
1106
|
+
full_name=f"{owner}/{name}",
|
|
1107
|
+
# owner and name are validated GitHub identifiers ([A-Za-z0-9._-]).
|
|
1108
|
+
github_url=f"https://github.com/{owner}/{name}",
|
|
1109
|
+
default_branch=row["default_branch"],
|
|
1110
|
+
protection=protection,
|
|
1111
|
+
protection_reason=reason,
|
|
1112
|
+
app_connection=app,
|
|
1113
|
+
monitoring_enabled=monitoring,
|
|
1114
|
+
last_scan=scan_summary(scan_row) if scan_row is not None else None,
|
|
1115
|
+
open_violations=row["open_violations"],
|
|
1116
|
+
open_warnings=row["open_warnings"],
|
|
1117
|
+
critical_open=row["critical_open"],
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
def repository_summaries(
|
|
1121
|
+
self, scope: AccessScope, *, organization_id: int | None = None
|
|
1122
|
+
) -> list[tuple[Row, RepositorySummary]]:
|
|
1123
|
+
"""Every repository visible in ``scope`` with its summary (organization views)."""
|
|
1124
|
+
return [
|
|
1125
|
+
(r.row, r.summary)
|
|
1126
|
+
for r in self._repository_rows(scope, organization_id=organization_id)
|
|
1127
|
+
]
|
|
1128
|
+
|
|
1129
|
+
def list_repositories(
|
|
1130
|
+
self, scope: AccessScope, filters: RepositoryFilters, *, offset: int, limit: int
|
|
1131
|
+
) -> Page[RepositorySummary]:
|
|
1132
|
+
rows = self._repository_rows(scope, organization_id=filters.organization_id, q=filters.q)
|
|
1133
|
+
summaries = [r.summary for r in rows]
|
|
1134
|
+
if filters.protection is not None:
|
|
1135
|
+
summaries = [s for s in summaries if s.protection is filters.protection]
|
|
1136
|
+
summaries = sort_repositories(summaries, filters.sort)
|
|
1137
|
+
page = summaries[offset : offset + limit]
|
|
1138
|
+
next_cursor = encode_cursor([offset + limit]) if len(summaries) > offset + limit else None
|
|
1139
|
+
return Page(page, next_cursor, limit)
|
|
1140
|
+
|
|
1141
|
+
def repository_ref(self, scope: AccessScope, repository_id: int) -> Row | None:
|
|
1142
|
+
"""The (installation, repository) row a caller can see, preferring a connected one."""
|
|
1143
|
+
rows = self._repository_rows(scope, repository_id=repository_id)
|
|
1144
|
+
if not rows:
|
|
1145
|
+
return None
|
|
1146
|
+
rows.sort(
|
|
1147
|
+
key=lambda r: (
|
|
1148
|
+
r.summary.app_connection is AppConnection.CONNECTED,
|
|
1149
|
+
r.row["latest_sequence"] or 0,
|
|
1150
|
+
),
|
|
1151
|
+
reverse=True,
|
|
1152
|
+
)
|
|
1153
|
+
return rows[0].row
|
|
1154
|
+
|
|
1155
|
+
def get_repository(
|
|
1156
|
+
self, scope: AccessScope, repository_id: int, *, principal: Principal
|
|
1157
|
+
) -> RepositoryDetail | None:
|
|
1158
|
+
rows = self._repository_rows(scope, repository_id=repository_id)
|
|
1159
|
+
if not rows:
|
|
1160
|
+
return None
|
|
1161
|
+
rows.sort(
|
|
1162
|
+
key=lambda r: (
|
|
1163
|
+
r.summary.app_connection is AppConnection.CONNECTED,
|
|
1164
|
+
r.row["latest_sequence"] or 0,
|
|
1165
|
+
),
|
|
1166
|
+
reverse=True,
|
|
1167
|
+
)
|
|
1168
|
+
chosen = rows[0]
|
|
1169
|
+
row, summary = chosen.row, chosen.summary
|
|
1170
|
+
installation_id = row["installation_id"]
|
|
1171
|
+
account_id = int(row["account_id"])
|
|
1172
|
+
recent = self._store.query(
|
|
1173
|
+
" ".join(
|
|
1174
|
+
(
|
|
1175
|
+
"SELECT",
|
|
1176
|
+
_SCAN_COLUMNS,
|
|
1177
|
+
"FROM scan_jobs j WHERE j.installation_id = ? AND "
|
|
1178
|
+
"j.repository_id = ? ORDER BY j.sequence DESC LIMIT 10",
|
|
1179
|
+
)
|
|
1180
|
+
),
|
|
1181
|
+
(installation_id, repository_id),
|
|
1182
|
+
)
|
|
1183
|
+
completed = self._store.query(
|
|
1184
|
+
"SELECT job_id, effective_policies, organization_policy_version, state, "
|
|
1185
|
+
"result_action, head_sha, completed_at FROM scan_jobs WHERE installation_id = ? AND "
|
|
1186
|
+
"repository_id = ? AND state IN ('passed', 'failed') ORDER BY sequence DESC LIMIT 1",
|
|
1187
|
+
(installation_id, repository_id),
|
|
1188
|
+
)
|
|
1189
|
+
latest_completed = completed[0] if completed else None
|
|
1190
|
+
violations = self._store.query(
|
|
1191
|
+
" ".join(
|
|
1192
|
+
(
|
|
1193
|
+
"SELECT",
|
|
1194
|
+
_VIOLATION_COLUMNS,
|
|
1195
|
+
"FROM violations v WHERE v.installation_id = ? AND "
|
|
1196
|
+
"v.repository_id = ? AND v.status = 'open' ORDER BY v.severity_rank DESC, "
|
|
1197
|
+
"v.last_detected_at DESC LIMIT 10",
|
|
1198
|
+
)
|
|
1199
|
+
),
|
|
1200
|
+
(installation_id, repository_id),
|
|
1201
|
+
)
|
|
1202
|
+
can_audit = principal.can(Permission.AUDIT_READ, account_id)
|
|
1203
|
+
audit: tuple[AuditEventView, ...] = ()
|
|
1204
|
+
if can_audit:
|
|
1205
|
+
events = self._store.query(
|
|
1206
|
+
"SELECT document FROM audit_events WHERE installation_id = ? AND repository_id = ? "
|
|
1207
|
+
"ORDER BY occurred_at DESC LIMIT 10",
|
|
1208
|
+
(installation_id, repository_id),
|
|
1209
|
+
)
|
|
1210
|
+
names = {(installation_id, repository_id): summary.full_name}
|
|
1211
|
+
audit = tuple(
|
|
1212
|
+
audit_view(AuditEvent.model_validate_json(e["document"]), names) for e in events
|
|
1213
|
+
)
|
|
1214
|
+
checked_at = _dt(row["checked_at"])
|
|
1215
|
+
enforcement = EnforcementView(
|
|
1216
|
+
github_app=EnforcementSignal(
|
|
1217
|
+
status=summary.app_connection.value,
|
|
1218
|
+
detail={
|
|
1219
|
+
AppConnection.CONNECTED: "The GitHub App can access this repository.",
|
|
1220
|
+
AppConnection.SUSPENDED: "The GitHub App installation is suspended.",
|
|
1221
|
+
AppConnection.DISCONNECTED: "The GitHub App no longer has access.",
|
|
1222
|
+
}[summary.app_connection],
|
|
1223
|
+
),
|
|
1224
|
+
github_actions=EnforcementSignal(
|
|
1225
|
+
status=row["actions"] or "unknown",
|
|
1226
|
+
detail=row["actions_detail"] or "Not checked yet.",
|
|
1227
|
+
checked_at=checked_at,
|
|
1228
|
+
),
|
|
1229
|
+
required_check=RequiredCheckSignal(
|
|
1230
|
+
status=RequiredCheckStatus(row["branch_protection"] or "unknown"),
|
|
1231
|
+
branch=row["branch"],
|
|
1232
|
+
required_checks=tuple(json.loads(row["required_checks"] or "[]")),
|
|
1233
|
+
detail=row["branch_protection_detail"] or "Not checked yet.",
|
|
1234
|
+
checked_at=checked_at,
|
|
1235
|
+
),
|
|
1236
|
+
latest_check=LatestCheckSignal(
|
|
1237
|
+
result=ScanResultStatus(
|
|
1238
|
+
scan_result_label(latest_completed["state"], latest_completed["result_action"])
|
|
1239
|
+
)
|
|
1240
|
+
if latest_completed
|
|
1241
|
+
else None,
|
|
1242
|
+
scan=latest_completed["job_id"] if latest_completed else None,
|
|
1243
|
+
head_sha=latest_completed["head_sha"] if latest_completed else None,
|
|
1244
|
+
completed_at=_dt(latest_completed["completed_at"]) if latest_completed else None,
|
|
1245
|
+
),
|
|
1246
|
+
local_hooks=EnforcementSignal(
|
|
1247
|
+
status="not_verifiable",
|
|
1248
|
+
detail="A server cannot see whether developers installed the Git hooks.",
|
|
1249
|
+
),
|
|
1250
|
+
merge_queue=EnforcementSignal(
|
|
1251
|
+
status=row["merge_queue"] or MergeQueueStatus.UNKNOWN.value,
|
|
1252
|
+
detail=row["merge_queue_detail"] or "Not checked yet.",
|
|
1253
|
+
checked_at=checked_at,
|
|
1254
|
+
),
|
|
1255
|
+
monitoring_enabled=summary.monitoring_enabled,
|
|
1256
|
+
)
|
|
1257
|
+
return RepositoryDetail(
|
|
1258
|
+
repository=summary,
|
|
1259
|
+
enforcement=enforcement,
|
|
1260
|
+
organization_policy_version=latest_completed["organization_policy_version"]
|
|
1261
|
+
if latest_completed
|
|
1262
|
+
else None,
|
|
1263
|
+
effective_policies=_policies(latest_completed["effective_policies"])
|
|
1264
|
+
if latest_completed
|
|
1265
|
+
else (),
|
|
1266
|
+
effective_policy_scan=latest_completed["job_id"] if latest_completed else None,
|
|
1267
|
+
recent_scans=tuple(scan_summary(r) for r in recent),
|
|
1268
|
+
open_violations=tuple(violation_summary(v) for v in violations),
|
|
1269
|
+
audit=audit,
|
|
1270
|
+
permissions=RepositoryPermissions(
|
|
1271
|
+
manage=principal.can(Permission.REPOSITORIES_MANAGE, account_id),
|
|
1272
|
+
trigger_scans=principal.can(Permission.SCANS_TRIGGER, account_id),
|
|
1273
|
+
read_audit=can_audit,
|
|
1274
|
+
),
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
def merge_queue(self, scope: AccessScope, repository_id: int) -> MergeQueueView | None:
|
|
1278
|
+
rows = self._repository_rows(scope, repository_id=repository_id)
|
|
1279
|
+
if not rows:
|
|
1280
|
+
return None
|
|
1281
|
+
rows.sort(
|
|
1282
|
+
key=lambda r: (
|
|
1283
|
+
r.summary.app_connection is AppConnection.CONNECTED,
|
|
1284
|
+
r.row["latest_sequence"] or 0,
|
|
1285
|
+
),
|
|
1286
|
+
reverse=True,
|
|
1287
|
+
)
|
|
1288
|
+
row = rows[0].row
|
|
1289
|
+
installation_id = int(row["installation_id"])
|
|
1290
|
+
records = self._store.list_merge_groups(installation_id, repository_id, limit=10)
|
|
1291
|
+
jobs: dict[str, Row] = {}
|
|
1292
|
+
ids = [r.job_id for r in records if r.job_id]
|
|
1293
|
+
if ids:
|
|
1294
|
+
for job in self._store.query(
|
|
1295
|
+
" ".join(
|
|
1296
|
+
(
|
|
1297
|
+
"SELECT",
|
|
1298
|
+
_SCAN_COLUMNS,
|
|
1299
|
+
"FROM scan_jobs j WHERE j.job_id IN (SELECT value FROM json_each(?))",
|
|
1300
|
+
)
|
|
1301
|
+
),
|
|
1302
|
+
(json.dumps(ids),),
|
|
1303
|
+
):
|
|
1304
|
+
jobs[job["job_id"]] = job
|
|
1305
|
+
views = [merge_group_view(r, jobs.get(r.job_id or "")) for r in records]
|
|
1306
|
+
current = next((v for v in views if v.state == "checks_requested"), None)
|
|
1307
|
+
installation = self._store.get_installation(installation_id)
|
|
1308
|
+
permission: Literal["granted", "missing"] = (
|
|
1309
|
+
"granted"
|
|
1310
|
+
if installation is not None
|
|
1311
|
+
and level_rank(installation.permissions.get("merge_queues")) >= level_rank("read")
|
|
1312
|
+
else "missing"
|
|
1313
|
+
)
|
|
1314
|
+
status = MergeQueueStatus(row["merge_queue"] or MergeQueueStatus.UNKNOWN.value)
|
|
1315
|
+
return MergeQueueView(
|
|
1316
|
+
repository_id=repository_id,
|
|
1317
|
+
status=status,
|
|
1318
|
+
detail=row["merge_queue_detail"]
|
|
1319
|
+
or "Merge queue settings have not been checked. Refresh the enforcement status.",
|
|
1320
|
+
checked_at=_dt(row["checked_at"]),
|
|
1321
|
+
permission=permission,
|
|
1322
|
+
current=current,
|
|
1323
|
+
recent=tuple(views),
|
|
1324
|
+
)
|
|
1325
|
+
|
|
1326
|
+
# ------------------------------------------------------------------ #
|
|
1327
|
+
# Audit
|
|
1328
|
+
# ------------------------------------------------------------------ #
|
|
1329
|
+
def list_audit(
|
|
1330
|
+
self, scope: AccessScope, filters: AuditFilters, *, cursor: str | None, limit: int
|
|
1331
|
+
) -> Page[AuditEventView]:
|
|
1332
|
+
if not scope.account_ids:
|
|
1333
|
+
return Page([], None, limit)
|
|
1334
|
+
where = _Where(
|
|
1335
|
+
SCOPE_AUDIT, scope.accounts_json, scope.installations_json, scope.session_hash
|
|
1336
|
+
)
|
|
1337
|
+
if filters.organization_id is not None:
|
|
1338
|
+
where.add("a.account_id = ?", filters.organization_id)
|
|
1339
|
+
if filters.repository_id is not None:
|
|
1340
|
+
where.add("a.repository_id = ?", filters.repository_id)
|
|
1341
|
+
if filters.event_type is not None:
|
|
1342
|
+
where.add("a.type = ?", filters.event_type.value)
|
|
1343
|
+
if filters.actor is not None:
|
|
1344
|
+
where.add("a.actor_login LIKE ? ESCAPE '\\'", like_pattern(filters.actor))
|
|
1345
|
+
if filters.start is not None:
|
|
1346
|
+
where.add("a.occurred_at >= ?", filters.start.timestamp())
|
|
1347
|
+
if filters.end is not None:
|
|
1348
|
+
where.add("a.occurred_at < ?", filters.end.timestamp())
|
|
1349
|
+
if filters.rule_id is not None:
|
|
1350
|
+
where.add(
|
|
1351
|
+
"(json_extract(a.document, '$.data.rule') = ? OR "
|
|
1352
|
+
"json_extract(a.document, '$.data.rules') = ?)",
|
|
1353
|
+
filters.rule_id,
|
|
1354
|
+
filters.rule_id,
|
|
1355
|
+
)
|
|
1356
|
+
if filters.exception_id is not None:
|
|
1357
|
+
where.add("json_extract(a.document, '$.data.exception') = ?", filters.exception_id)
|
|
1358
|
+
if filters.policy == "organization":
|
|
1359
|
+
where.add(
|
|
1360
|
+
"a.type IN ('organization_policy_changed', 'organization_policy_rolled_back', "
|
|
1361
|
+
"'policy_emergency_published', 'organization_settings_changed')"
|
|
1362
|
+
)
|
|
1363
|
+
elif filters.policy is not None:
|
|
1364
|
+
where.add(
|
|
1365
|
+
"(json_extract(a.document, '$.data.draft') = ? OR "
|
|
1366
|
+
"json_extract(a.document, '$.data.target_id') = ? OR "
|
|
1367
|
+
"json_extract(a.document, '$.data.group') = ? OR "
|
|
1368
|
+
"json_extract(a.document, '$.data.rollout') = ?)",
|
|
1369
|
+
filters.policy,
|
|
1370
|
+
filters.policy,
|
|
1371
|
+
filters.policy,
|
|
1372
|
+
filters.policy,
|
|
1373
|
+
)
|
|
1374
|
+
newest = filters.sort != "oldest"
|
|
1375
|
+
# Ties on the timestamp are broken by insertion order (rowid), so events
|
|
1376
|
+
# recorded in one transaction keep their order.
|
|
1377
|
+
position = decode_cursor(cursor, (float, int))
|
|
1378
|
+
if position is not None:
|
|
1379
|
+
where.add(
|
|
1380
|
+
"(a.occurred_at < ? OR (a.occurred_at = ? AND a.rowid < ?))"
|
|
1381
|
+
if newest
|
|
1382
|
+
else "(a.occurred_at > ? OR (a.occurred_at = ? AND a.rowid > ?))",
|
|
1383
|
+
position[0],
|
|
1384
|
+
position[0],
|
|
1385
|
+
position[1],
|
|
1386
|
+
)
|
|
1387
|
+
order = "a.occurred_at DESC, a.rowid DESC" if newest else "a.occurred_at ASC, a.rowid ASC"
|
|
1388
|
+
rows = self._store.query(
|
|
1389
|
+
" ".join(
|
|
1390
|
+
(
|
|
1391
|
+
"SELECT a.rowid AS position, a.occurred_at, a.document FROM audit_events a "
|
|
1392
|
+
"WHERE",
|
|
1393
|
+
where.sql,
|
|
1394
|
+
"ORDER BY",
|
|
1395
|
+
order,
|
|
1396
|
+
"LIMIT ?",
|
|
1397
|
+
)
|
|
1398
|
+
),
|
|
1399
|
+
(*where.params, limit + 1),
|
|
1400
|
+
)
|
|
1401
|
+
events = [AuditEvent.model_validate_json(r["document"]) for r in rows[:limit]]
|
|
1402
|
+
names = self._full_names(events)
|
|
1403
|
+
items = [audit_view(e, names) for e in events]
|
|
1404
|
+
next_cursor = None
|
|
1405
|
+
if len(rows) > limit:
|
|
1406
|
+
last = rows[limit - 1]
|
|
1407
|
+
next_cursor = encode_cursor([last["occurred_at"], last["position"]])
|
|
1408
|
+
return Page(items, next_cursor, limit)
|
|
1409
|
+
|
|
1410
|
+
def get_audit_event(self, scope: AccessScope, event_id: str) -> AuditEventView | None:
|
|
1411
|
+
if not scope.account_ids or not _is_hex_id(event_id):
|
|
1412
|
+
return None
|
|
1413
|
+
rows = self._store.query(
|
|
1414
|
+
" ".join(
|
|
1415
|
+
("SELECT a.document FROM audit_events a WHERE a.event_id = ? AND", SCOPE_AUDIT)
|
|
1416
|
+
),
|
|
1417
|
+
(event_id, scope.accounts_json, scope.installations_json, scope.session_hash),
|
|
1418
|
+
)
|
|
1419
|
+
if not rows:
|
|
1420
|
+
return None
|
|
1421
|
+
event = AuditEvent.model_validate_json(rows[0]["document"])
|
|
1422
|
+
return audit_view(event, self._full_names([event]))
|
|
1423
|
+
|
|
1424
|
+
def _full_names(self, events: Sequence[AuditEvent]) -> dict[tuple[int, int], str]:
|
|
1425
|
+
keys = sorted(
|
|
1426
|
+
{
|
|
1427
|
+
(e.installation_id, e.repository_id)
|
|
1428
|
+
for e in events
|
|
1429
|
+
if e.installation_id is not None and e.repository_id is not None
|
|
1430
|
+
}
|
|
1431
|
+
)
|
|
1432
|
+
names: dict[tuple[int, int], str] = {}
|
|
1433
|
+
for installation_id, repository_id in keys:
|
|
1434
|
+
rows = self._store.query(
|
|
1435
|
+
"SELECT owner, name FROM known_repositories WHERE installation_id = ? "
|
|
1436
|
+
"AND repository_id = ?",
|
|
1437
|
+
(installation_id, repository_id),
|
|
1438
|
+
)
|
|
1439
|
+
if rows:
|
|
1440
|
+
names[(installation_id, repository_id)] = f"{rows[0]['owner']}/{rows[0]['name']}"
|
|
1441
|
+
return names
|
|
1442
|
+
|
|
1443
|
+
# ------------------------------------------------------------------ #
|
|
1444
|
+
# GitHub installations
|
|
1445
|
+
# ------------------------------------------------------------------ #
|
|
1446
|
+
def _installation_rows(
|
|
1447
|
+
self, scope: AccessScope, installation_id: int | None = None
|
|
1448
|
+
) -> list[Row]:
|
|
1449
|
+
if not scope.installation_ids:
|
|
1450
|
+
return []
|
|
1451
|
+
where = _Where(SCOPE_INSTALLATIONS, scope.installations_json)
|
|
1452
|
+
if installation_id is not None:
|
|
1453
|
+
where.add("i.installation_id = ?", installation_id)
|
|
1454
|
+
return self._store.query(
|
|
1455
|
+
" ".join(
|
|
1456
|
+
(
|
|
1457
|
+
"SELECT i.*, (SELECT COUNT(*) FROM installation_repositories ir WHERE "
|
|
1458
|
+
"ir.installation_id = i.installation_id) AS repository_count, (SELECT "
|
|
1459
|
+
"MAX(occurred_at) FROM audit_events a WHERE a.installation_id = "
|
|
1460
|
+
"i.installation_id) AS last_event_at FROM installations i WHERE",
|
|
1461
|
+
where.sql,
|
|
1462
|
+
"ORDER BY i.account_login COLLATE NOCASE, i.installation_id",
|
|
1463
|
+
)
|
|
1464
|
+
),
|
|
1465
|
+
where.params,
|
|
1466
|
+
)
|
|
1467
|
+
|
|
1468
|
+
@staticmethod
|
|
1469
|
+
def _installation_view(row: Row, principal: Principal) -> InstallationView:
|
|
1470
|
+
permissions = json.loads(row["permissions"] or "{}")
|
|
1471
|
+
state = row["state"]
|
|
1472
|
+
status = {
|
|
1473
|
+
"active": AppConnection.CONNECTED,
|
|
1474
|
+
"suspended": AppConnection.SUSPENDED,
|
|
1475
|
+
}.get(state, AppConnection.DISCONNECTED)
|
|
1476
|
+
login = row["account_login"]
|
|
1477
|
+
if row["account_type"] == "Organization":
|
|
1478
|
+
url = f"https://github.com/organizations/{login}/settings/installations/{row['installation_id']}"
|
|
1479
|
+
else:
|
|
1480
|
+
url = f"https://github.com/settings/installations/{row['installation_id']}"
|
|
1481
|
+
return InstallationView(
|
|
1482
|
+
id=row["installation_id"],
|
|
1483
|
+
account=OrganizationRef(id=row["account_id"], login=login, type=row["account_type"]),
|
|
1484
|
+
status=status,
|
|
1485
|
+
repository_selection=row["repository_selection"],
|
|
1486
|
+
repositories=row["repository_count"],
|
|
1487
|
+
permissions=permissions,
|
|
1488
|
+
missing_permissions=tuple(
|
|
1489
|
+
f"{k}: {v}"
|
|
1490
|
+
for k, v in missing_permissions(permissions, REQUIRED_PERMISSIONS).items()
|
|
1491
|
+
),
|
|
1492
|
+
excessive_permissions=tuple(
|
|
1493
|
+
f"{k}: {v}"
|
|
1494
|
+
for k, v in excessive_permissions(permissions, REQUIRED_PERMISSIONS).items()
|
|
1495
|
+
),
|
|
1496
|
+
installed_at=_req_dt(row["created_at"]),
|
|
1497
|
+
updated_at=_req_dt(row["updated_at"]),
|
|
1498
|
+
last_event_at=_dt(row["last_event_at"]),
|
|
1499
|
+
github_settings_url=url,
|
|
1500
|
+
can_manage=principal.can(Permission.GITHUB_MANAGE, int(row["account_id"])),
|
|
1501
|
+
)
|
|
1502
|
+
|
|
1503
|
+
def list_installations(
|
|
1504
|
+
self, scope: AccessScope, principal: Principal
|
|
1505
|
+
) -> list[InstallationView]:
|
|
1506
|
+
return [self._installation_view(r, principal) for r in self._installation_rows(scope)]
|
|
1507
|
+
|
|
1508
|
+
def get_installation(
|
|
1509
|
+
self, scope: AccessScope, installation_id: int, principal: Principal
|
|
1510
|
+
) -> InstallationDetail | None:
|
|
1511
|
+
rows = self._installation_rows(scope, installation_id)
|
|
1512
|
+
if not rows:
|
|
1513
|
+
return None
|
|
1514
|
+
view = self._installation_view(rows[0], principal)
|
|
1515
|
+
events: tuple[AuditEventView, ...] = ()
|
|
1516
|
+
if principal.can(Permission.AUDIT_READ, view.account.id):
|
|
1517
|
+
audit_scope = principal.scope(Permission.AUDIT_READ, account_id=view.account.id)
|
|
1518
|
+
page = self.list_audit(
|
|
1519
|
+
audit_scope,
|
|
1520
|
+
AuditFilters(organization_id=view.account.id),
|
|
1521
|
+
cursor=None,
|
|
1522
|
+
limit=10,
|
|
1523
|
+
)
|
|
1524
|
+
events = tuple(e for e in page.items if e.installation_id in (installation_id, None))
|
|
1525
|
+
return InstallationDetail(installation=view, recent_events=events)
|
|
1526
|
+
|
|
1527
|
+
def installation_repositories(
|
|
1528
|
+
self, scope: AccessScope, installation_id: int, *, offset: int, limit: int
|
|
1529
|
+
) -> Page[InstallationRepositoryView] | None:
|
|
1530
|
+
if not self._installation_rows(scope, installation_id):
|
|
1531
|
+
return None
|
|
1532
|
+
rows = self._store.query(
|
|
1533
|
+
"SELECT r.repository_id, r.owner, r.name, r.first_seen_at, r.removed_at, EXISTS "
|
|
1534
|
+
"(SELECT 1 FROM installation_repositories ir WHERE ir.installation_id = "
|
|
1535
|
+
"r.installation_id AND ir.repository_id = r.repository_id) AS listed, "
|
|
1536
|
+
"COALESCE((SELECT monitoring_enabled FROM repository_settings s WHERE "
|
|
1537
|
+
"s.installation_id = r.installation_id AND s.repository_id = r.repository_id), 1) AS "
|
|
1538
|
+
"monitoring_enabled FROM known_repositories r WHERE r.installation_id = ? AND EXISTS "
|
|
1539
|
+
"(SELECT 1 FROM session_repositories sr WHERE sr.session_hash = ? AND "
|
|
1540
|
+
"sr.installation_id = r.installation_id AND sr.repository_id = r.repository_id) "
|
|
1541
|
+
"ORDER BY listed DESC, r.owner COLLATE NOCASE, r.name COLLATE NOCASE LIMIT ? OFFSET ?",
|
|
1542
|
+
(installation_id, scope.session_hash, limit + 1, offset),
|
|
1543
|
+
)
|
|
1544
|
+
items = [
|
|
1545
|
+
InstallationRepositoryView(
|
|
1546
|
+
id=r["repository_id"],
|
|
1547
|
+
full_name=f"{r['owner']}/{r['name']}",
|
|
1548
|
+
connected=bool(r["listed"]),
|
|
1549
|
+
monitoring_enabled=bool(r["monitoring_enabled"]),
|
|
1550
|
+
added_at=_dt(r["first_seen_at"]),
|
|
1551
|
+
removed_at=_dt(r["removed_at"]),
|
|
1552
|
+
)
|
|
1553
|
+
for r in rows[:limit]
|
|
1554
|
+
]
|
|
1555
|
+
next_cursor = encode_cursor([offset + limit]) if len(rows) > limit else None
|
|
1556
|
+
return Page(items, next_cursor, limit)
|
|
1557
|
+
|
|
1558
|
+
# ------------------------------------------------------------------ #
|
|
1559
|
+
# Overview
|
|
1560
|
+
# ------------------------------------------------------------------ #
|
|
1561
|
+
def overview(
|
|
1562
|
+
self,
|
|
1563
|
+
principal: Principal,
|
|
1564
|
+
*,
|
|
1565
|
+
period: str,
|
|
1566
|
+
organization_id: int | None,
|
|
1567
|
+
) -> OverviewView:
|
|
1568
|
+
now = self._now()
|
|
1569
|
+
start = now - PERIODS[period]
|
|
1570
|
+
repo_scope = principal.scope(Permission.REPOSITORIES_READ, account_id=organization_id)
|
|
1571
|
+
scan_scope = principal.scope(Permission.SCANS_READ, account_id=organization_id)
|
|
1572
|
+
violation_scope = principal.scope(Permission.VIOLATIONS_READ, account_id=organization_id)
|
|
1573
|
+
|
|
1574
|
+
repositories = [r.summary for r in self._repository_rows(repo_scope)]
|
|
1575
|
+
monitored = [r for r in repositories if r.app_connection is AppConnection.CONNECTED]
|
|
1576
|
+
by_protection = {
|
|
1577
|
+
status: sum(1 for r in monitored if r.protection is status)
|
|
1578
|
+
for status in ProtectionStatus
|
|
1579
|
+
}
|
|
1580
|
+
scan_counts = {"total": 0, "passed": 0, "blocked": 0, "error": 0}
|
|
1581
|
+
if scan_scope.installation_ids:
|
|
1582
|
+
row = self._store.query(
|
|
1583
|
+
" ".join(
|
|
1584
|
+
(
|
|
1585
|
+
"SELECT COUNT(*) AS total, SUM(j.state = 'passed') AS passed, "
|
|
1586
|
+
"SUM(j.state = 'failed') AS blocked, SUM(j.state = 'error') AS error "
|
|
1587
|
+
"FROM scan_jobs j WHERE",
|
|
1588
|
+
SCOPE_JOBS,
|
|
1589
|
+
"AND j.created_at >= ?",
|
|
1590
|
+
)
|
|
1591
|
+
),
|
|
1592
|
+
(scan_scope.installations_json, scan_scope.session_hash, start.timestamp()),
|
|
1593
|
+
)[0]
|
|
1594
|
+
scan_counts = {k: int(row[k] or 0) for k in scan_counts}
|
|
1595
|
+
violation_counts = {"block": 0, "warn": 0, "critical": 0, "high": 0}
|
|
1596
|
+
if violation_scope.installation_ids:
|
|
1597
|
+
row = self._store.query(
|
|
1598
|
+
" ".join(
|
|
1599
|
+
(
|
|
1600
|
+
"SELECT SUM(v.action = 'block') AS block, SUM(v.action = 'warn') AS warn, "
|
|
1601
|
+
"SUM(v.severity = 'critical') AS critical, "
|
|
1602
|
+
"SUM(v.severity = 'high') AS high "
|
|
1603
|
+
"FROM violations v WHERE",
|
|
1604
|
+
SCOPE_VIOLATIONS,
|
|
1605
|
+
"AND v.status = 'open'",
|
|
1606
|
+
)
|
|
1607
|
+
),
|
|
1608
|
+
(violation_scope.installations_json, violation_scope.session_hash),
|
|
1609
|
+
)[0]
|
|
1610
|
+
violation_counts = {k: int(row[k] or 0) for k in violation_counts}
|
|
1611
|
+
|
|
1612
|
+
installations = self.list_installations(repo_scope, principal)
|
|
1613
|
+
integration = _integration(installations)
|
|
1614
|
+
recent_scans = self.list_scans(scan_scope, ScanFilters(), cursor=None, limit=8).items
|
|
1615
|
+
recent_violations = self.list_violations(
|
|
1616
|
+
violation_scope, ViolationFilters(), cursor=None, limit=8
|
|
1617
|
+
).items
|
|
1618
|
+
health_list = sort_repositories(repositories, "risk")[:8]
|
|
1619
|
+
summary = OverviewSummary(
|
|
1620
|
+
repositories_monitored=len(monitored),
|
|
1621
|
+
repositories_protected=by_protection[ProtectionStatus.PROTECTED],
|
|
1622
|
+
# Not "monitored": the App lost access, which is exactly what puts them at risk.
|
|
1623
|
+
repositories_at_risk=sum(
|
|
1624
|
+
1 for r in repositories if r.protection is ProtectionStatus.AT_RISK
|
|
1625
|
+
),
|
|
1626
|
+
repositories_unprotected=by_protection[ProtectionStatus.UNPROTECTED],
|
|
1627
|
+
repositories_unknown=by_protection[ProtectionStatus.UNKNOWN],
|
|
1628
|
+
repositories_configuration_error=by_protection[ProtectionStatus.CONFIGURATION_ERROR],
|
|
1629
|
+
scans=scan_counts["total"],
|
|
1630
|
+
scans_passed=scan_counts["passed"],
|
|
1631
|
+
scans_blocked=scan_counts["blocked"],
|
|
1632
|
+
scans_error=scan_counts["error"],
|
|
1633
|
+
open_violations=violation_counts["block"],
|
|
1634
|
+
open_warnings=violation_counts["warn"],
|
|
1635
|
+
critical_open=violation_counts["critical"],
|
|
1636
|
+
high_open=violation_counts["high"],
|
|
1637
|
+
)
|
|
1638
|
+
return OverviewView(
|
|
1639
|
+
period=OverviewPeriod(key=period, start=start, end=now), # type: ignore[arg-type]
|
|
1640
|
+
summary=summary,
|
|
1641
|
+
integration=integration,
|
|
1642
|
+
health=_health_checks(summary, integration, len(monitored)),
|
|
1643
|
+
recent_scans=tuple(recent_scans),
|
|
1644
|
+
recent_violations=tuple(recent_violations),
|
|
1645
|
+
repository_health=tuple(health_list),
|
|
1646
|
+
)
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def _is_hex_id(value: str) -> bool:
|
|
1650
|
+
return len(value) == 32 and all(c in "0123456789abcdef" for c in value)
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
_RISK_ORDER = {
|
|
1654
|
+
ProtectionStatus.AT_RISK: 0,
|
|
1655
|
+
ProtectionStatus.CONFIGURATION_ERROR: 1,
|
|
1656
|
+
ProtectionStatus.UNPROTECTED: 2,
|
|
1657
|
+
ProtectionStatus.UNKNOWN: 3,
|
|
1658
|
+
ProtectionStatus.PROTECTED: 4,
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
|
|
1662
|
+
def sort_repositories(items: list[RepositorySummary], sort: str) -> list[RepositorySummary]:
|
|
1663
|
+
if sort == "risk":
|
|
1664
|
+
return sorted(
|
|
1665
|
+
items,
|
|
1666
|
+
key=lambda r: (
|
|
1667
|
+
-r.critical_open,
|
|
1668
|
+
-(1 if r.last_scan and r.last_scan.result is ScanResultStatus.BLOCKED else 0),
|
|
1669
|
+
-r.open_violations,
|
|
1670
|
+
_RISK_ORDER[r.protection],
|
|
1671
|
+
r.full_name.lower(),
|
|
1672
|
+
),
|
|
1673
|
+
)
|
|
1674
|
+
if sort == "recent":
|
|
1675
|
+
return sorted(
|
|
1676
|
+
items,
|
|
1677
|
+
key=lambda r: (
|
|
1678
|
+
-(r.last_scan.created_at.timestamp() if r.last_scan else 0),
|
|
1679
|
+
r.full_name.lower(),
|
|
1680
|
+
),
|
|
1681
|
+
)
|
|
1682
|
+
return sorted(items, key=lambda r: (r.full_name.lower(), r.installation_id))
|
|
1683
|
+
|
|
1684
|
+
|
|
1685
|
+
def _integration(installations: Sequence[InstallationView]) -> IntegrationView:
|
|
1686
|
+
connected = sum(1 for i in installations if i.status is AppConnection.CONNECTED)
|
|
1687
|
+
suspended = sum(1 for i in installations if i.status is AppConnection.SUSPENDED)
|
|
1688
|
+
disconnected = sum(1 for i in installations if i.status is AppConnection.DISCONNECTED)
|
|
1689
|
+
missing = any(
|
|
1690
|
+
i.missing_permissions for i in installations if i.status is AppConnection.CONNECTED
|
|
1691
|
+
)
|
|
1692
|
+
if connected and not suspended and not missing:
|
|
1693
|
+
return IntegrationView(
|
|
1694
|
+
status=IntegrationStatus.CONNECTED,
|
|
1695
|
+
installations_connected=connected,
|
|
1696
|
+
installations_suspended=suspended,
|
|
1697
|
+
installations_disconnected=disconnected,
|
|
1698
|
+
detail="The GitHub App is installed and has the permissions CommitGuard needs.",
|
|
1699
|
+
)
|
|
1700
|
+
if connected or suspended:
|
|
1701
|
+
detail = (
|
|
1702
|
+
"An installation is suspended."
|
|
1703
|
+
if suspended
|
|
1704
|
+
else "An installation is missing permissions."
|
|
1705
|
+
)
|
|
1706
|
+
return IntegrationView(
|
|
1707
|
+
status=IntegrationStatus.ACTION_REQUIRED,
|
|
1708
|
+
installations_connected=connected,
|
|
1709
|
+
installations_suspended=suspended,
|
|
1710
|
+
installations_disconnected=disconnected,
|
|
1711
|
+
detail=detail,
|
|
1712
|
+
)
|
|
1713
|
+
return IntegrationView(
|
|
1714
|
+
status=IntegrationStatus.DISCONNECTED,
|
|
1715
|
+
installations_connected=0,
|
|
1716
|
+
installations_suspended=0,
|
|
1717
|
+
installations_disconnected=disconnected,
|
|
1718
|
+
detail="No active GitHub App installation is available to you.",
|
|
1719
|
+
)
|
|
1720
|
+
|
|
1721
|
+
|
|
1722
|
+
def _health_checks(
|
|
1723
|
+
summary: OverviewSummary, integration: IntegrationView, monitored: int
|
|
1724
|
+
) -> tuple[HealthCheck, ...]:
|
|
1725
|
+
"""Explicit checks instead of a score (see docs/dashboard.md, "Security health")."""
|
|
1726
|
+
checks = [
|
|
1727
|
+
HealthCheck(
|
|
1728
|
+
id="github_integration",
|
|
1729
|
+
label="GitHub integration",
|
|
1730
|
+
status=HealthCheckStatus.OK
|
|
1731
|
+
if integration.status is IntegrationStatus.CONNECTED
|
|
1732
|
+
else HealthCheckStatus.ATTENTION,
|
|
1733
|
+
detail=integration.detail,
|
|
1734
|
+
),
|
|
1735
|
+
]
|
|
1736
|
+
if monitored == 0:
|
|
1737
|
+
checks.append(
|
|
1738
|
+
HealthCheck(
|
|
1739
|
+
id="required_check",
|
|
1740
|
+
label="Merge protection",
|
|
1741
|
+
status=HealthCheckStatus.UNKNOWN,
|
|
1742
|
+
detail="No repositories are monitored.",
|
|
1743
|
+
)
|
|
1744
|
+
)
|
|
1745
|
+
else:
|
|
1746
|
+
if (
|
|
1747
|
+
summary.repositories_at_risk
|
|
1748
|
+
or summary.repositories_unprotected
|
|
1749
|
+
or summary.repositories_configuration_error
|
|
1750
|
+
):
|
|
1751
|
+
status = HealthCheckStatus.ATTENTION
|
|
1752
|
+
elif summary.repositories_unknown:
|
|
1753
|
+
status = HealthCheckStatus.UNKNOWN
|
|
1754
|
+
else:
|
|
1755
|
+
status = HealthCheckStatus.OK
|
|
1756
|
+
checks.append(
|
|
1757
|
+
HealthCheck(
|
|
1758
|
+
id="required_check",
|
|
1759
|
+
label="Merge protection",
|
|
1760
|
+
status=status,
|
|
1761
|
+
detail=(
|
|
1762
|
+
f"{summary.repositories_protected} of {monitored} monitored repositories are "
|
|
1763
|
+
"verified to require a CommitGuard check; "
|
|
1764
|
+
f"{summary.repositories_unknown} not verified"
|
|
1765
|
+
+ (
|
|
1766
|
+
f"; {summary.repositories_at_risk} at risk (GitHub App access lost)."
|
|
1767
|
+
if summary.repositories_at_risk
|
|
1768
|
+
else "."
|
|
1769
|
+
)
|
|
1770
|
+
),
|
|
1771
|
+
)
|
|
1772
|
+
)
|
|
1773
|
+
checks.append(
|
|
1774
|
+
HealthCheck(
|
|
1775
|
+
id="critical_violations",
|
|
1776
|
+
label="Critical violations",
|
|
1777
|
+
status=HealthCheckStatus.OK
|
|
1778
|
+
if summary.critical_open == 0
|
|
1779
|
+
else HealthCheckStatus.ATTENTION,
|
|
1780
|
+
detail=f"{summary.critical_open} open critical violation(s).",
|
|
1781
|
+
)
|
|
1782
|
+
)
|
|
1783
|
+
checks.append(
|
|
1784
|
+
HealthCheck(
|
|
1785
|
+
id="open_violations",
|
|
1786
|
+
label="Open violations",
|
|
1787
|
+
status=HealthCheckStatus.OK
|
|
1788
|
+
if summary.open_violations == 0
|
|
1789
|
+
else HealthCheckStatus.ATTENTION,
|
|
1790
|
+
detail=f"{summary.open_violations} blocking violation(s) are currently present.",
|
|
1791
|
+
)
|
|
1792
|
+
)
|
|
1793
|
+
checks.append(
|
|
1794
|
+
HealthCheck(
|
|
1795
|
+
id="scan_errors",
|
|
1796
|
+
label="Scan reliability",
|
|
1797
|
+
status=HealthCheckStatus.OK
|
|
1798
|
+
if summary.scans_error == 0
|
|
1799
|
+
else HealthCheckStatus.ATTENTION,
|
|
1800
|
+
detail=f"{summary.scans_error} scan(s) could not be completed in this period.",
|
|
1801
|
+
)
|
|
1802
|
+
)
|
|
1803
|
+
checks.append(
|
|
1804
|
+
HealthCheck(
|
|
1805
|
+
id="configuration",
|
|
1806
|
+
label="Configuration",
|
|
1807
|
+
status=HealthCheckStatus.OK
|
|
1808
|
+
if summary.repositories_configuration_error == 0
|
|
1809
|
+
else HealthCheckStatus.ATTENTION,
|
|
1810
|
+
detail=f"{summary.repositories_configuration_error} repositories have an invalid "
|
|
1811
|
+
"CommitGuard configuration.",
|
|
1812
|
+
)
|
|
1813
|
+
)
|
|
1814
|
+
return tuple(checks)
|