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,1272 @@
|
|
|
1
|
+
"""Organization security posture, repository matrix, drift, trends, reports and search.
|
|
2
|
+
|
|
3
|
+
Posture is a small set of explicit states, never a score
|
|
4
|
+
=========================================================
|
|
5
|
+
|
|
6
|
+
Repository posture - the **first** rule that matches decides:
|
|
7
|
+
|
|
8
|
+
==== ================================================================== ===============
|
|
9
|
+
# Condition Posture
|
|
10
|
+
==== ================================================================== ===============
|
|
11
|
+
1 the GitHub App installation is suspended or lost access ``at_risk``
|
|
12
|
+
2 CommitGuard monitoring is paused ``unprotected``
|
|
13
|
+
3 GitHub does not require a CommitGuard check (branch protection) ``unprotected``
|
|
14
|
+
4 the effective policy could not be resolved (propagation ``error``) ``at_risk``
|
|
15
|
+
5 the latest scan failed on invalid CommitGuard configuration ``at_risk``
|
|
16
|
+
6 open critical violations ``at_risk``
|
|
17
|
+
7 monitor mode (violations are reported, not blocked) ``at_risk``
|
|
18
|
+
8 an active exception lowers a high or critical rule ``at_risk``
|
|
19
|
+
9 branch protection has not been verified ``unknown``
|
|
20
|
+
10 otherwise ``secure``
|
|
21
|
+
==== ================================================================== ===============
|
|
22
|
+
|
|
23
|
+
Organization posture - again the first matching rule decides:
|
|
24
|
+
|
|
25
|
+
* ``unknown`` - the organization has no repositories;
|
|
26
|
+
* ``at_risk`` - any installation suspended, removed or failing to synchronise,
|
|
27
|
+
any repository ``at_risk``, or some (not all) repositories ``unprotected``;
|
|
28
|
+
* ``unprotected`` - every repository is proven ``unprotected`` (monitoring paused
|
|
29
|
+
or no CommitGuard check required by GitHub);
|
|
30
|
+
* ``unknown`` - otherwise, if the protection of any repository is not verified;
|
|
31
|
+
* ``secure`` - every repository is ``secure`` and installations are healthy.
|
|
32
|
+
|
|
33
|
+
**Compliance** is shown only as a fraction with its definition: "*N of M
|
|
34
|
+
required repositories satisfy all mandatory controls*", where required
|
|
35
|
+
repositories are onboarded, not archived and connected, and satisfying means
|
|
36
|
+
posture ``secure``. It is a policy compliance report - not a SOC 2, ISO or any
|
|
37
|
+
other certification, and the reports say so.
|
|
38
|
+
|
|
39
|
+
Policy drift compares what a repository's own configuration asked for with what
|
|
40
|
+
organization governance requires, using the latest scan's recorded provenance
|
|
41
|
+
(the repository configuration is only known at scan time):
|
|
42
|
+
|
|
43
|
+
* ``compliant`` - no conflicts; ``customized`` - the repository changes
|
|
44
|
+
defaults the organization allows it to change; ``drift`` - the repository asks
|
|
45
|
+
for less than a mandatory requirement (the requirement still applies; each
|
|
46
|
+
difference is listed); ``unknown`` - no scan recorded provenance yet.
|
|
47
|
+
|
|
48
|
+
Staleness: computed values carry ``computed_at``. Trends that cannot be derived
|
|
49
|
+
from scan and violation history (protection, exceptions, drift) come from daily
|
|
50
|
+
snapshots written by :meth:`SecurityPostureService.snapshot_metrics`, and say so.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
import csv
|
|
54
|
+
import io
|
|
55
|
+
import json
|
|
56
|
+
import sqlite3
|
|
57
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
58
|
+
from datetime import UTC, datetime, timedelta
|
|
59
|
+
from typing import Any, Literal
|
|
60
|
+
|
|
61
|
+
from pydantic import BaseModel, ConfigDict
|
|
62
|
+
|
|
63
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
64
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
65
|
+
from commitguard.controlplane.errors import ConflictError, InputValidationError, NotFoundError
|
|
66
|
+
from commitguard.controlplane.pagination import like_pattern
|
|
67
|
+
from commitguard.controlplane.queries import DashboardQueries
|
|
68
|
+
from commitguard.controlplane.rules import CATALOG
|
|
69
|
+
from commitguard.controlplane.views import AppConnection, ProtectionStatus, RepositorySummary
|
|
70
|
+
from commitguard.core.result import Severity
|
|
71
|
+
from commitguard.github.storage import SqliteStateStore
|
|
72
|
+
from commitguard.governance.common import (
|
|
73
|
+
dt,
|
|
74
|
+
is_hex_id,
|
|
75
|
+
req_dt,
|
|
76
|
+
require,
|
|
77
|
+
text,
|
|
78
|
+
ts,
|
|
79
|
+
visible_repository_ids,
|
|
80
|
+
)
|
|
81
|
+
from commitguard.governance.exceptions import rule_severity
|
|
82
|
+
from commitguard.governance.inventory import governance_states
|
|
83
|
+
from commitguard.governance.settings import load_settings
|
|
84
|
+
from commitguard.policies.governance import EffectivePolicy, RepositoryMode
|
|
85
|
+
from commitguard.services.audit import AuditService
|
|
86
|
+
|
|
87
|
+
Posture = Literal["secure", "at_risk", "unprotected", "unknown"]
|
|
88
|
+
Drift = Literal["compliant", "customized", "drift", "unknown"]
|
|
89
|
+
SNAPSHOT_MAX_AGE = timedelta(minutes=5)
|
|
90
|
+
MATRIX_SORTS = ("name", "posture", "violations", "last_scan")
|
|
91
|
+
POSTURE_ORDER = {"at_risk": 0, "unprotected": 1, "unknown": 2, "secure": 3}
|
|
92
|
+
REPORT_KINDS = (
|
|
93
|
+
"compliance",
|
|
94
|
+
"violations",
|
|
95
|
+
"coverage",
|
|
96
|
+
"exceptions",
|
|
97
|
+
"policy_changes",
|
|
98
|
+
"installations",
|
|
99
|
+
)
|
|
100
|
+
#: A synchronisation still running after this long is reported as failed (crashed).
|
|
101
|
+
SYNC_STALLED_AFTER = timedelta(minutes=15)
|
|
102
|
+
#: Rows per report; a larger result is cut and the summary says ``truncated``.
|
|
103
|
+
REPORT_ROW_LIMIT = 10_000
|
|
104
|
+
NOT_A_CERTIFICATION = (
|
|
105
|
+
"Policy compliance report generated by CommitGuard. It describes CommitGuard policy "
|
|
106
|
+
"enforcement at the time shown; it is not a SOC 2, ISO 27001 or any other certification "
|
|
107
|
+
"or attestation."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class _View(BaseModel):
|
|
112
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class DriftDifference(_View):
|
|
116
|
+
policy_id: str
|
|
117
|
+
requested: str
|
|
118
|
+
requested_by: str
|
|
119
|
+
required: str
|
|
120
|
+
required_by: str
|
|
121
|
+
effective: str
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class RepositoryPosture(_View):
|
|
125
|
+
repository_id: int
|
|
126
|
+
full_name: str
|
|
127
|
+
github_url: str
|
|
128
|
+
installation_id: int
|
|
129
|
+
groups: tuple[dict[str, str], ...]
|
|
130
|
+
connection: str
|
|
131
|
+
archived: bool
|
|
132
|
+
onboarding: str
|
|
133
|
+
mode: RepositoryMode
|
|
134
|
+
protection: ProtectionStatus
|
|
135
|
+
protection_reason: str
|
|
136
|
+
posture: Posture
|
|
137
|
+
posture_reasons: tuple[str, ...]
|
|
138
|
+
organization_policy_version: int | None
|
|
139
|
+
policy_state: str
|
|
140
|
+
last_scan_result: str | None
|
|
141
|
+
last_scan_at: datetime | None
|
|
142
|
+
open_violations: int
|
|
143
|
+
open_warnings: int
|
|
144
|
+
critical_open: int
|
|
145
|
+
active_exceptions: int
|
|
146
|
+
expiring_exceptions: int
|
|
147
|
+
drift: Drift
|
|
148
|
+
drift_differences: tuple[DriftDifference, ...]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class MatrixPage(_View):
|
|
152
|
+
items: tuple[RepositoryPosture, ...]
|
|
153
|
+
total: int
|
|
154
|
+
next_cursor: str | None
|
|
155
|
+
computed_at: datetime
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
class InstallationHealth(_View):
|
|
159
|
+
installation_id: int
|
|
160
|
+
account_login: str
|
|
161
|
+
state: str # active | suspended | deleted
|
|
162
|
+
sync: Literal["healthy", "syncing", "degraded", "failed", "never"]
|
|
163
|
+
sync_detail: str
|
|
164
|
+
last_success_at: datetime | None
|
|
165
|
+
repositories: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class OrganizationPostureView(_View):
|
|
169
|
+
organization_id: int
|
|
170
|
+
login: str
|
|
171
|
+
type: str
|
|
172
|
+
github_url: str
|
|
173
|
+
posture: Posture
|
|
174
|
+
posture_reasons: tuple[str, ...]
|
|
175
|
+
members: int
|
|
176
|
+
repositories: int
|
|
177
|
+
required_repositories: int
|
|
178
|
+
compliant_repositories: int
|
|
179
|
+
compliance: str # "94 of 100 required repositories satisfy all mandatory controls"
|
|
180
|
+
by_posture: dict[str, int]
|
|
181
|
+
by_protection: dict[str, int]
|
|
182
|
+
monitor_mode: int
|
|
183
|
+
critical_open: int
|
|
184
|
+
high_open: int
|
|
185
|
+
active_exceptions: int
|
|
186
|
+
expiring_exceptions: int
|
|
187
|
+
expired_exceptions_30d: int
|
|
188
|
+
drift: dict[str, int]
|
|
189
|
+
installations: tuple[InstallationHealth, ...]
|
|
190
|
+
policy: dict[str, Any]
|
|
191
|
+
recent_activity: tuple[dict[str, Any], ...]
|
|
192
|
+
computed_at: datetime
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class TrendPoint(_View):
|
|
196
|
+
day: str
|
|
197
|
+
values: dict[str, int]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TrendsView(_View):
|
|
201
|
+
organization_id: int
|
|
202
|
+
days: int
|
|
203
|
+
history: tuple[TrendPoint, ...] # from scans and violations
|
|
204
|
+
snapshots: tuple[TrendPoint, ...] # from daily metric snapshots
|
|
205
|
+
snapshot_note: str
|
|
206
|
+
computed_at: datetime
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class SearchResult(_View):
|
|
210
|
+
kind: str
|
|
211
|
+
id: str
|
|
212
|
+
title: str
|
|
213
|
+
detail: str
|
|
214
|
+
link: str
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def repository_posture(
|
|
218
|
+
*,
|
|
219
|
+
summary: RepositorySummary,
|
|
220
|
+
mode: RepositoryMode,
|
|
221
|
+
policy_state: str,
|
|
222
|
+
high_exceptions: int,
|
|
223
|
+
) -> tuple[Posture, tuple[str, ...]]:
|
|
224
|
+
"""The explicit posture rules (module docstring). Returns the posture and why."""
|
|
225
|
+
if summary.app_connection is not AppConnection.CONNECTED:
|
|
226
|
+
return "at_risk", (summary.protection_reason,)
|
|
227
|
+
if not summary.monitoring_enabled:
|
|
228
|
+
return "unprotected", ("CommitGuard monitoring is paused.",)
|
|
229
|
+
if summary.protection is ProtectionStatus.UNPROTECTED:
|
|
230
|
+
return "unprotected", (summary.protection_reason,)
|
|
231
|
+
reasons: list[str] = []
|
|
232
|
+
if policy_state == "error":
|
|
233
|
+
reasons.append("The effective policy could not be resolved.")
|
|
234
|
+
if summary.protection is ProtectionStatus.CONFIGURATION_ERROR:
|
|
235
|
+
reasons.append(summary.protection_reason)
|
|
236
|
+
if summary.critical_open:
|
|
237
|
+
reasons.append(f"{summary.critical_open} open critical violation(s).")
|
|
238
|
+
if mode is RepositoryMode.MONITOR:
|
|
239
|
+
reasons.append("Monitor mode: violations are reported but not blocked.")
|
|
240
|
+
if high_exceptions:
|
|
241
|
+
reasons.append(f"{high_exceptions} active exception(s) lower high or critical rules.")
|
|
242
|
+
if reasons:
|
|
243
|
+
return "at_risk", tuple(reasons)
|
|
244
|
+
if summary.protection is ProtectionStatus.UNKNOWN:
|
|
245
|
+
return "unknown", (summary.protection_reason,)
|
|
246
|
+
return "secure", ("Protected, enforcing, no open critical violations.",)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def drift_from(effective: EffectivePolicy | None) -> tuple[Drift, tuple[DriftDifference, ...]]:
|
|
250
|
+
if effective is None:
|
|
251
|
+
return "unknown", ()
|
|
252
|
+
conflicts = tuple(
|
|
253
|
+
DriftDifference(
|
|
254
|
+
policy_id=c.policy_id,
|
|
255
|
+
requested="disabled" if not c.requested_enabled else c.requested_action.value,
|
|
256
|
+
requested_by=c.requested_label,
|
|
257
|
+
required=c.required_action.value,
|
|
258
|
+
required_by=c.required_label,
|
|
259
|
+
effective=c.effective_action.value,
|
|
260
|
+
)
|
|
261
|
+
for c in effective.conflicts
|
|
262
|
+
)
|
|
263
|
+
if conflicts:
|
|
264
|
+
return "drift", conflicts
|
|
265
|
+
customized = any(r.source.value == "repository_configuration" for r in effective.rules)
|
|
266
|
+
return ("customized" if customized else "compliant"), ()
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class SecurityPostureService:
|
|
270
|
+
def __init__(
|
|
271
|
+
self,
|
|
272
|
+
store: SqliteStateStore,
|
|
273
|
+
audit: AuditService,
|
|
274
|
+
queries: DashboardQueries,
|
|
275
|
+
*,
|
|
276
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
277
|
+
) -> None:
|
|
278
|
+
self._store = store
|
|
279
|
+
self._audit = audit
|
|
280
|
+
self._queries = queries
|
|
281
|
+
self._now = now
|
|
282
|
+
|
|
283
|
+
# -- data ------------------------------------------------------------- #
|
|
284
|
+
def _postures(self, principal: Principal, account_id: int) -> list[RepositoryPosture]:
|
|
285
|
+
"""Posture of every repository of the organization the caller can see."""
|
|
286
|
+
scope = principal.scope(Permission.REPOSITORIES_READ, account_id=account_id)
|
|
287
|
+
rows = self._queries.repository_summaries(scope, organization_id=account_id)
|
|
288
|
+
# One row per repository ID: prefer the connected installation's row.
|
|
289
|
+
chosen: dict[int, tuple[sqlite3.Row, RepositorySummary]] = {}
|
|
290
|
+
for row, summary in rows:
|
|
291
|
+
current = chosen.get(summary.id)
|
|
292
|
+
if current is None or (
|
|
293
|
+
current[1].app_connection is not AppConnection.CONNECTED
|
|
294
|
+
and summary.app_connection is AppConnection.CONNECTED
|
|
295
|
+
):
|
|
296
|
+
chosen[summary.id] = (row, summary)
|
|
297
|
+
states = governance_states(self._store, account_id)
|
|
298
|
+
default_mode = load_settings(self._store, account_id).settings.default_onboarding_mode
|
|
299
|
+
extras = self._repository_extras(account_id, [s.id for _, s in chosen.values()])
|
|
300
|
+
result = []
|
|
301
|
+
for _row, summary in chosen.values():
|
|
302
|
+
extra = extras.get(summary.id, {})
|
|
303
|
+
state = states.get(summary.id)
|
|
304
|
+
mode = state.mode if state else default_mode
|
|
305
|
+
policy_state = str(extra.get("policy_state", "pending"))
|
|
306
|
+
posture, reasons = repository_posture(
|
|
307
|
+
summary=summary,
|
|
308
|
+
mode=mode,
|
|
309
|
+
policy_state=policy_state,
|
|
310
|
+
high_exceptions=int(extra.get("high_exceptions", 0)),
|
|
311
|
+
)
|
|
312
|
+
drift, differences = drift_from(extra.get("effective"))
|
|
313
|
+
result.append(
|
|
314
|
+
RepositoryPosture(
|
|
315
|
+
repository_id=summary.id,
|
|
316
|
+
full_name=summary.full_name,
|
|
317
|
+
github_url=summary.github_url,
|
|
318
|
+
installation_id=summary.installation_id,
|
|
319
|
+
groups=tuple(extra.get("groups", ())),
|
|
320
|
+
connection=summary.app_connection.value,
|
|
321
|
+
archived=bool(extra.get("archived", False)),
|
|
322
|
+
onboarding=state.onboarding if state else "discovered",
|
|
323
|
+
mode=mode,
|
|
324
|
+
protection=summary.protection,
|
|
325
|
+
protection_reason=summary.protection_reason,
|
|
326
|
+
posture=posture,
|
|
327
|
+
posture_reasons=reasons,
|
|
328
|
+
organization_policy_version=extra.get("organization_policy_version"),
|
|
329
|
+
policy_state=policy_state,
|
|
330
|
+
last_scan_result=summary.last_scan.result.value if summary.last_scan else None,
|
|
331
|
+
last_scan_at=summary.last_scan.created_at if summary.last_scan else None,
|
|
332
|
+
open_violations=summary.open_violations,
|
|
333
|
+
open_warnings=summary.open_warnings,
|
|
334
|
+
critical_open=summary.critical_open,
|
|
335
|
+
active_exceptions=int(extra.get("active_exceptions", 0)),
|
|
336
|
+
expiring_exceptions=int(extra.get("expiring_exceptions", 0)),
|
|
337
|
+
drift=drift,
|
|
338
|
+
drift_differences=differences,
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
return result
|
|
342
|
+
|
|
343
|
+
def _repository_extras(
|
|
344
|
+
self, account_id: int, repository_ids: Sequence[int]
|
|
345
|
+
) -> dict[int, dict[str, Any]]:
|
|
346
|
+
"""Groups, exceptions, propagation and last-scan provenance, in a few queries."""
|
|
347
|
+
now = self._now()
|
|
348
|
+
extras: dict[int, dict[str, Any]] = {i: {"groups": []} for i in repository_ids}
|
|
349
|
+
ids_json = json.dumps(list(repository_ids))
|
|
350
|
+
group_members: dict[str, list[int]] = {}
|
|
351
|
+
for row in self._store.query(
|
|
352
|
+
"SELECT m.repository_id, g.group_id, g.name FROM repository_group_members m JOIN "
|
|
353
|
+
"repository_groups g ON g.group_id = m.group_id WHERE m.account_id = ? "
|
|
354
|
+
"AND g.archived_at IS NULL ORDER BY g.name_key",
|
|
355
|
+
(account_id,),
|
|
356
|
+
):
|
|
357
|
+
repository_id = int(row["repository_id"])
|
|
358
|
+
group_members.setdefault(str(row["group_id"]), []).append(repository_id)
|
|
359
|
+
if repository_id in extras:
|
|
360
|
+
extras[repository_id]["groups"].append(
|
|
361
|
+
{"id": str(row["group_id"]), "name": str(row["name"])}
|
|
362
|
+
)
|
|
363
|
+
for row in self._store.query(
|
|
364
|
+
"SELECT rule_id, scope_type, scope_id, expires_at, permanent FROM policy_exceptions "
|
|
365
|
+
"WHERE account_id = ? AND status = 'active' AND (permanent = 1 OR expires_at > ?)",
|
|
366
|
+
(account_id, ts(now)),
|
|
367
|
+
):
|
|
368
|
+
if row["scope_type"] == "organization":
|
|
369
|
+
targets: Sequence[int] = repository_ids
|
|
370
|
+
elif row["scope_type"] == "group":
|
|
371
|
+
targets = group_members.get(str(row["scope_id"]), [])
|
|
372
|
+
else:
|
|
373
|
+
targets = [int(row["scope_id"])] if str(row["scope_id"]).isdigit() else []
|
|
374
|
+
high = rule_severity(str(row["rule_id"])).rank >= Severity.HIGH.rank
|
|
375
|
+
expiring = (
|
|
376
|
+
not row["permanent"]
|
|
377
|
+
and row["expires_at"] is not None
|
|
378
|
+
and float(row["expires_at"]) - ts(now) <= timedelta(days=7).total_seconds()
|
|
379
|
+
)
|
|
380
|
+
for repository_id in targets:
|
|
381
|
+
if repository_id not in extras:
|
|
382
|
+
continue
|
|
383
|
+
entry = extras[repository_id]
|
|
384
|
+
entry["active_exceptions"] = entry.get("active_exceptions", 0) + 1
|
|
385
|
+
if high:
|
|
386
|
+
entry["high_exceptions"] = entry.get("high_exceptions", 0) + 1
|
|
387
|
+
if expiring:
|
|
388
|
+
entry["expiring_exceptions"] = entry.get("expiring_exceptions", 0) + 1
|
|
389
|
+
for row in self._store.query(
|
|
390
|
+
"SELECT repository_id, state, valid_until FROM repository_effective_policies "
|
|
391
|
+
"WHERE account_id = ? AND repository_id IN (SELECT value FROM json_each(?))",
|
|
392
|
+
(account_id, ids_json),
|
|
393
|
+
):
|
|
394
|
+
state = str(row["state"])
|
|
395
|
+
if (
|
|
396
|
+
state == "up_to_date"
|
|
397
|
+
and row["valid_until"]
|
|
398
|
+
and float(row["valid_until"]) <= ts(now)
|
|
399
|
+
):
|
|
400
|
+
state = "stale"
|
|
401
|
+
extras[int(row["repository_id"])]["policy_state"] = state
|
|
402
|
+
for row in self._store.query(
|
|
403
|
+
"SELECT k.repository_id, MAX(k.archived) AS archived FROM known_repositories k "
|
|
404
|
+
"JOIN installations i ON i.installation_id = k.installation_id WHERE "
|
|
405
|
+
"i.account_id = ? GROUP BY k.repository_id",
|
|
406
|
+
(account_id,),
|
|
407
|
+
):
|
|
408
|
+
if int(row["repository_id"]) in extras:
|
|
409
|
+
extras[int(row["repository_id"])]["archived"] = bool(row["archived"])
|
|
410
|
+
# The organization policy version of each repository's latest completed scan
|
|
411
|
+
# (also scans recorded before Phase 8, which carry no governance record).
|
|
412
|
+
for row in self._store.query(
|
|
413
|
+
"SELECT j.repository_id, j.organization_policy_version FROM scan_jobs j JOIN "
|
|
414
|
+
"installations i ON i.installation_id = j.installation_id WHERE i.account_id = ? "
|
|
415
|
+
"AND j.completed_at IS NOT NULL AND j.sequence = (SELECT MAX(n.sequence) FROM "
|
|
416
|
+
"scan_jobs n WHERE n.installation_id = j.installation_id AND n.repository_id = "
|
|
417
|
+
"j.repository_id AND n.completed_at IS NOT NULL)",
|
|
418
|
+
(account_id,),
|
|
419
|
+
):
|
|
420
|
+
if int(row["repository_id"]) in extras:
|
|
421
|
+
extras[int(row["repository_id"])]["organization_policy_version"] = row[
|
|
422
|
+
"organization_policy_version"
|
|
423
|
+
]
|
|
424
|
+
for row in self._store.query(
|
|
425
|
+
"SELECT j.repository_id, j.governance FROM scan_jobs j "
|
|
426
|
+
"JOIN installations i ON i.installation_id = j.installation_id WHERE "
|
|
427
|
+
"i.account_id = ? AND j.governance IS NOT NULL AND j.sequence = (SELECT "
|
|
428
|
+
"MAX(n.sequence) "
|
|
429
|
+
"FROM scan_jobs n WHERE n.installation_id = j.installation_id AND n.repository_id = "
|
|
430
|
+
"j.repository_id AND n.governance IS NOT NULL)",
|
|
431
|
+
(account_id,),
|
|
432
|
+
):
|
|
433
|
+
repository_id = int(row["repository_id"])
|
|
434
|
+
if repository_id not in extras:
|
|
435
|
+
continue
|
|
436
|
+
try:
|
|
437
|
+
record = json.loads(str(row["governance"]))
|
|
438
|
+
effective = record.get("effective")
|
|
439
|
+
extras[repository_id]["effective"] = (
|
|
440
|
+
EffectivePolicy.model_validate(effective) if effective else None
|
|
441
|
+
)
|
|
442
|
+
except (ValueError, TypeError):
|
|
443
|
+
continue
|
|
444
|
+
return extras
|
|
445
|
+
|
|
446
|
+
# -- organization ----------------------------------------------------- #
|
|
447
|
+
def _organization_ref(self, principal: Principal, account_id: int) -> tuple[str, str]:
|
|
448
|
+
membership = principal.memberships.get(account_id)
|
|
449
|
+
if membership is None:
|
|
450
|
+
raise NotFoundError()
|
|
451
|
+
return membership.account_login, membership.account_type
|
|
452
|
+
|
|
453
|
+
def installations(self, account_id: int) -> tuple[InstallationHealth, ...]:
|
|
454
|
+
now = self._now()
|
|
455
|
+
rows = self._store.query(
|
|
456
|
+
"SELECT i.installation_id, i.account_login, i.state, s.state AS sync_state, "
|
|
457
|
+
"s.started_at, s.last_success_at, s.error, s.repositories, (SELECT COUNT(*) FROM "
|
|
458
|
+
"installation_repositories r WHERE r.installation_id = i.installation_id) AS listed "
|
|
459
|
+
"FROM installations i LEFT JOIN installation_sync_status s ON s.installation_id = "
|
|
460
|
+
"i.installation_id WHERE i.account_id = ? AND i.state != 'deleted' "
|
|
461
|
+
"ORDER BY i.installation_id",
|
|
462
|
+
(account_id,),
|
|
463
|
+
)
|
|
464
|
+
result = []
|
|
465
|
+
for row in rows:
|
|
466
|
+
last_success = dt(row["last_success_at"])
|
|
467
|
+
sync: Literal["healthy", "syncing", "degraded", "failed", "never"]
|
|
468
|
+
if row["sync_state"] == "failed":
|
|
469
|
+
sync, detail = "failed", str(row["error"] or "The last synchronisation failed.")
|
|
470
|
+
elif row["sync_state"] == "syncing" and (
|
|
471
|
+
row["started_at"] is None or now - req_dt(row["started_at"]) > SYNC_STALLED_AFTER
|
|
472
|
+
):
|
|
473
|
+
sync, detail = "failed", "The last synchronisation did not finish."
|
|
474
|
+
elif row["sync_state"] == "syncing":
|
|
475
|
+
sync, detail = "syncing", "Synchronising repositories with GitHub."
|
|
476
|
+
elif row["state"] != "active":
|
|
477
|
+
sync, detail = "degraded", f"The installation is {row['state']}."
|
|
478
|
+
elif last_success is None:
|
|
479
|
+
sync, detail = (
|
|
480
|
+
"never",
|
|
481
|
+
"Repositories are known from GitHub events; no full synchronisation yet.",
|
|
482
|
+
)
|
|
483
|
+
elif now - last_success > timedelta(days=7):
|
|
484
|
+
sync, detail = "degraded", "Not synchronised with GitHub in the last 7 days."
|
|
485
|
+
else:
|
|
486
|
+
sync, detail = "healthy", "Synchronised with GitHub."
|
|
487
|
+
result.append(
|
|
488
|
+
InstallationHealth(
|
|
489
|
+
installation_id=int(row["installation_id"]),
|
|
490
|
+
account_login=str(row["account_login"]),
|
|
491
|
+
state=str(row["state"]),
|
|
492
|
+
sync=sync,
|
|
493
|
+
sync_detail=detail,
|
|
494
|
+
last_success_at=last_success,
|
|
495
|
+
repositories=int(row["listed"]),
|
|
496
|
+
)
|
|
497
|
+
)
|
|
498
|
+
return tuple(result)
|
|
499
|
+
|
|
500
|
+
def overview(self, principal: Principal, account_id: int) -> OrganizationPostureView:
|
|
501
|
+
require(principal, Permission.SECURITY_READ, account_id)
|
|
502
|
+
login, account_type = self._organization_ref(principal, account_id)
|
|
503
|
+
now = self._now()
|
|
504
|
+
postures = self._postures(principal, account_id)
|
|
505
|
+
states = governance_states(self._store, account_id)
|
|
506
|
+
installations = self.installations(account_id)
|
|
507
|
+
required = [
|
|
508
|
+
p
|
|
509
|
+
for p in postures
|
|
510
|
+
if p.connection == "connected"
|
|
511
|
+
and not p.archived
|
|
512
|
+
and (
|
|
513
|
+
states[p.repository_id].onboarding == "onboarded"
|
|
514
|
+
if p.repository_id in states
|
|
515
|
+
else True
|
|
516
|
+
)
|
|
517
|
+
]
|
|
518
|
+
compliant = sum(1 for p in required if p.posture == "secure")
|
|
519
|
+
by_posture = {k: sum(1 for p in postures if p.posture == k) for k in POSTURE_ORDER}
|
|
520
|
+
by_protection = {
|
|
521
|
+
status.value: sum(1 for p in postures if p.protection is status)
|
|
522
|
+
for status in ProtectionStatus
|
|
523
|
+
}
|
|
524
|
+
reasons: list[str] = []
|
|
525
|
+
unhealthy = [i for i in installations if i.state != "active" or i.sync == "failed"]
|
|
526
|
+
if not postures:
|
|
527
|
+
posture: Posture = "unknown"
|
|
528
|
+
reasons.append("No repositories are connected to CommitGuard yet.")
|
|
529
|
+
elif (
|
|
530
|
+
unhealthy
|
|
531
|
+
or by_posture["at_risk"]
|
|
532
|
+
or (by_posture["unprotected"] and by_posture["unprotected"] < len(postures))
|
|
533
|
+
):
|
|
534
|
+
posture = "at_risk"
|
|
535
|
+
for installation in unhealthy:
|
|
536
|
+
reasons.append(
|
|
537
|
+
f"Installation {installation.account_login} ({installation.installation_id}): "
|
|
538
|
+
f"{installation.sync_detail}"
|
|
539
|
+
)
|
|
540
|
+
if by_posture["at_risk"]:
|
|
541
|
+
reasons.append(f"{by_posture['at_risk']} repository(ies) at risk.")
|
|
542
|
+
if by_posture["unprotected"]:
|
|
543
|
+
reasons.append(f"{by_posture['unprotected']} repository(ies) unprotected.")
|
|
544
|
+
elif by_posture["unprotected"] == len(postures):
|
|
545
|
+
posture = "unprotected"
|
|
546
|
+
reasons.append(
|
|
547
|
+
"No repository is protected: CommitGuard checks do not block merges anywhere."
|
|
548
|
+
)
|
|
549
|
+
elif by_posture["unknown"]:
|
|
550
|
+
posture = "unknown"
|
|
551
|
+
reasons.append(
|
|
552
|
+
f"Branch protection of {by_posture['unknown']} repository(ies) is not verified."
|
|
553
|
+
)
|
|
554
|
+
else:
|
|
555
|
+
posture = "secure"
|
|
556
|
+
reasons.append("Every repository is protected and enforcing; installations healthy.")
|
|
557
|
+
members = int(
|
|
558
|
+
self._store.query(
|
|
559
|
+
"SELECT COUNT(*) AS n FROM memberships WHERE account_id = ?", (account_id,)
|
|
560
|
+
)[0]["n"]
|
|
561
|
+
)
|
|
562
|
+
exception_counts = self._store.query(
|
|
563
|
+
"SELECT SUM(status = 'active' AND (permanent = 1 OR expires_at > ?)) AS active, "
|
|
564
|
+
"SUM(status = 'active' AND permanent = 0 AND expires_at > ? AND expires_at <= ?) "
|
|
565
|
+
"AS expiring, SUM(status = 'expired' AND expired_at >= ?) AS expired, "
|
|
566
|
+
"SUM(status = 'requested') AS requested FROM policy_exceptions WHERE account_id = ?",
|
|
567
|
+
(
|
|
568
|
+
ts(now),
|
|
569
|
+
ts(now),
|
|
570
|
+
ts(now + timedelta(days=7)),
|
|
571
|
+
ts(now - timedelta(days=30)),
|
|
572
|
+
account_id,
|
|
573
|
+
),
|
|
574
|
+
)[0]
|
|
575
|
+
return OrganizationPostureView(
|
|
576
|
+
organization_id=account_id,
|
|
577
|
+
login=login,
|
|
578
|
+
type=account_type,
|
|
579
|
+
github_url=f"https://github.com/{login}",
|
|
580
|
+
posture=posture,
|
|
581
|
+
posture_reasons=tuple(reasons),
|
|
582
|
+
members=members,
|
|
583
|
+
repositories=len(postures),
|
|
584
|
+
required_repositories=len(required),
|
|
585
|
+
compliant_repositories=compliant,
|
|
586
|
+
compliance=(
|
|
587
|
+
f"{compliant} of {len(required)} required repositories satisfy all mandatory "
|
|
588
|
+
"controls"
|
|
589
|
+
),
|
|
590
|
+
by_posture=by_posture,
|
|
591
|
+
by_protection=by_protection,
|
|
592
|
+
monitor_mode=sum(1 for p in postures if p.mode is RepositoryMode.MONITOR),
|
|
593
|
+
critical_open=sum(p.critical_open for p in postures),
|
|
594
|
+
high_open=self._high_open(account_id, [p.repository_id for p in postures]),
|
|
595
|
+
active_exceptions=int(exception_counts["active"] or 0),
|
|
596
|
+
expiring_exceptions=int(exception_counts["expiring"] or 0),
|
|
597
|
+
expired_exceptions_30d=int(exception_counts["expired"] or 0),
|
|
598
|
+
drift={
|
|
599
|
+
k: sum(1 for p in postures if p.drift == k)
|
|
600
|
+
for k in ("compliant", "customized", "drift", "unknown")
|
|
601
|
+
},
|
|
602
|
+
installations=installations,
|
|
603
|
+
policy=self._policy_status(account_id, int(exception_counts["requested"] or 0)),
|
|
604
|
+
recent_activity=self._recent_activity(principal, account_id),
|
|
605
|
+
computed_at=now,
|
|
606
|
+
)
|
|
607
|
+
|
|
608
|
+
def _high_open(self, account_id: int, repository_ids: Sequence[int]) -> int:
|
|
609
|
+
"""Open high violations of the repositories the caller can see."""
|
|
610
|
+
return int(
|
|
611
|
+
self._store.query(
|
|
612
|
+
"SELECT COUNT(*) AS n FROM violations v JOIN installations i ON "
|
|
613
|
+
"i.installation_id = v.installation_id WHERE i.account_id = ? "
|
|
614
|
+
"AND v.status = 'open' AND v.severity = 'high' "
|
|
615
|
+
"AND v.repository_id IN (SELECT value FROM json_each(?))",
|
|
616
|
+
(account_id, json.dumps(sorted(set(repository_ids)))),
|
|
617
|
+
)[0]["n"]
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
def _policy_status(self, account_id: int, requested_exceptions: int) -> dict[str, Any]:
|
|
621
|
+
latest = self._store.query(
|
|
622
|
+
"SELECT version, created_at, created_by_login FROM organization_policy_versions "
|
|
623
|
+
"WHERE account_id = ? ORDER BY version DESC LIMIT 1",
|
|
624
|
+
(account_id,),
|
|
625
|
+
)
|
|
626
|
+
pending = self._store.query(
|
|
627
|
+
"SELECT COUNT(*) AS n FROM policy_drafts WHERE account_id = ? "
|
|
628
|
+
"AND state = 'pending_approval'",
|
|
629
|
+
(account_id,),
|
|
630
|
+
)[0]["n"]
|
|
631
|
+
rollouts = self._store.query(
|
|
632
|
+
"SELECT COUNT(*) AS n FROM policy_rollouts WHERE account_id = ? "
|
|
633
|
+
"AND state IN ('pilot', 'rollout', 'paused')",
|
|
634
|
+
(account_id,),
|
|
635
|
+
)[0]["n"]
|
|
636
|
+
propagation = {
|
|
637
|
+
str(r["state"]): int(r["n"])
|
|
638
|
+
for r in self._store.query(
|
|
639
|
+
"SELECT state, COUNT(*) AS n FROM repository_effective_policies "
|
|
640
|
+
"WHERE account_id = ? GROUP BY state",
|
|
641
|
+
(account_id,),
|
|
642
|
+
)
|
|
643
|
+
}
|
|
644
|
+
baseline = load_settings(self._store, account_id).settings.security_baseline
|
|
645
|
+
return {
|
|
646
|
+
"organization_version": int(latest[0]["version"]) if latest else 0,
|
|
647
|
+
"updated_at": req_dt(latest[0]["created_at"]).isoformat() if latest else None,
|
|
648
|
+
"updated_by": latest[0]["created_by_login"] if latest else None,
|
|
649
|
+
"approvals_pending": int(pending),
|
|
650
|
+
"exceptions_requested": requested_exceptions,
|
|
651
|
+
"rollouts_in_progress": int(rollouts),
|
|
652
|
+
"propagation": propagation,
|
|
653
|
+
"baseline": {k: v.value for k, v in baseline.items()},
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
def _recent_activity(self, principal: Principal, account_id: int) -> tuple[dict[str, Any], ...]:
|
|
657
|
+
if not principal.can(Permission.AUDIT_READ, account_id):
|
|
658
|
+
return ()
|
|
659
|
+
types = (
|
|
660
|
+
"organization_policy_changed",
|
|
661
|
+
"organization_policy_rolled_back",
|
|
662
|
+
"policy_published",
|
|
663
|
+
"policy_emergency_published",
|
|
664
|
+
"exception_approved",
|
|
665
|
+
"exception_revoked",
|
|
666
|
+
"exception_expired",
|
|
667
|
+
"installation_suspended",
|
|
668
|
+
"installation_removed",
|
|
669
|
+
"installation_unsuspended",
|
|
670
|
+
"policy_rollout_paused",
|
|
671
|
+
"organization_settings_changed",
|
|
672
|
+
"repository_mode_changed",
|
|
673
|
+
)
|
|
674
|
+
rows = self._store.query(
|
|
675
|
+
"SELECT event_id, occurred_at, type, actor_login, repository_id FROM audit_events "
|
|
676
|
+
"WHERE account_id = ? AND type IN (SELECT value FROM json_each(?)) "
|
|
677
|
+
"ORDER BY occurred_at DESC LIMIT 12",
|
|
678
|
+
(account_id, json.dumps(types)),
|
|
679
|
+
)
|
|
680
|
+
return tuple(
|
|
681
|
+
{
|
|
682
|
+
"id": str(r["event_id"]),
|
|
683
|
+
"type": str(r["type"]),
|
|
684
|
+
"occurred_at": req_dt(r["occurred_at"]).isoformat(),
|
|
685
|
+
"actor": r["actor_login"],
|
|
686
|
+
}
|
|
687
|
+
for r in rows
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
# -- matrix ----------------------------------------------------------- #
|
|
691
|
+
def matrix(
|
|
692
|
+
self,
|
|
693
|
+
principal: Principal,
|
|
694
|
+
account_id: int,
|
|
695
|
+
*,
|
|
696
|
+
filters: Mapping[str, str | None],
|
|
697
|
+
offset: int,
|
|
698
|
+
limit: int,
|
|
699
|
+
) -> MatrixPage:
|
|
700
|
+
require(principal, Permission.SECURITY_READ, account_id)
|
|
701
|
+
now = self._now()
|
|
702
|
+
items = self._postures(principal, account_id)
|
|
703
|
+
q = filters.get("q")
|
|
704
|
+
if q:
|
|
705
|
+
needle = q.casefold()
|
|
706
|
+
items = [p for p in items if needle in p.full_name.casefold()]
|
|
707
|
+
group = filters.get("group")
|
|
708
|
+
if group:
|
|
709
|
+
if not is_hex_id(group):
|
|
710
|
+
raise InputValidationError("group must be a group ID", field="group")
|
|
711
|
+
items = [p for p in items if any(g["id"] == group for g in p.groups)]
|
|
712
|
+
checks: dict[str, tuple[set[str], Callable[[RepositoryPosture], str]]] = {
|
|
713
|
+
"posture": ({"secure", "at_risk", "unprotected", "unknown"}, lambda p: p.posture),
|
|
714
|
+
"protection": ({s.value for s in ProtectionStatus}, lambda p: p.protection.value),
|
|
715
|
+
"mode": ({"monitor", "enforce"}, lambda p: p.mode.value),
|
|
716
|
+
"onboarding": ({"discovered", "onboarded", "excluded"}, lambda p: p.onboarding),
|
|
717
|
+
"policy_state": (
|
|
718
|
+
{"up_to_date", "stale", "syncing", "error", "pending"},
|
|
719
|
+
lambda p: p.policy_state,
|
|
720
|
+
),
|
|
721
|
+
"drift": ({"compliant", "customized", "drift", "unknown"}, lambda p: p.drift),
|
|
722
|
+
}
|
|
723
|
+
for name, (allowed, getter) in checks.items():
|
|
724
|
+
value = filters.get(name)
|
|
725
|
+
if value:
|
|
726
|
+
if value not in allowed:
|
|
727
|
+
raise InputValidationError(f"unknown {name}", field=name)
|
|
728
|
+
items = [p for p in items if getter(p) == value]
|
|
729
|
+
exceptions = filters.get("exceptions")
|
|
730
|
+
if exceptions == "active":
|
|
731
|
+
items = [p for p in items if p.active_exceptions]
|
|
732
|
+
elif exceptions == "expiring":
|
|
733
|
+
items = [p for p in items if p.expiring_exceptions]
|
|
734
|
+
elif exceptions == "none":
|
|
735
|
+
items = [p for p in items if not p.active_exceptions]
|
|
736
|
+
elif exceptions:
|
|
737
|
+
raise InputValidationError(
|
|
738
|
+
"exceptions must be active, expiring or none", field="exceptions"
|
|
739
|
+
)
|
|
740
|
+
severity = filters.get("severity")
|
|
741
|
+
if severity == "critical":
|
|
742
|
+
items = [p for p in items if p.critical_open]
|
|
743
|
+
elif severity == "any":
|
|
744
|
+
items = [p for p in items if p.open_violations or p.open_warnings]
|
|
745
|
+
elif severity:
|
|
746
|
+
raise InputValidationError("severity must be critical or any", field="severity")
|
|
747
|
+
last_scan = filters.get("last_scan")
|
|
748
|
+
if last_scan == "never":
|
|
749
|
+
items = [p for p in items if p.last_scan_at is None]
|
|
750
|
+
elif last_scan and last_scan.isdigit() and 0 < int(last_scan) <= 365:
|
|
751
|
+
cutoff = now - timedelta(days=int(last_scan))
|
|
752
|
+
items = [p for p in items if p.last_scan_at is None or p.last_scan_at < cutoff]
|
|
753
|
+
elif last_scan:
|
|
754
|
+
raise InputValidationError(
|
|
755
|
+
"last_scan must be never or a number of days", field="last_scan"
|
|
756
|
+
)
|
|
757
|
+
sort = filters.get("sort") or "posture"
|
|
758
|
+
if sort not in MATRIX_SORTS:
|
|
759
|
+
raise InputValidationError("unknown sort", field="sort")
|
|
760
|
+
epoch = datetime.fromtimestamp(0, UTC)
|
|
761
|
+
keys: dict[str, Callable[[RepositoryPosture], Any]] = {
|
|
762
|
+
"name": lambda p: p.full_name.casefold(),
|
|
763
|
+
"posture": lambda p: (POSTURE_ORDER[p.posture], -p.critical_open, p.full_name),
|
|
764
|
+
"violations": lambda p: (-p.critical_open, -p.open_violations, p.full_name),
|
|
765
|
+
"last_scan": lambda p: (p.last_scan_at or epoch, p.full_name),
|
|
766
|
+
}
|
|
767
|
+
items.sort(key=keys[sort])
|
|
768
|
+
page = items[offset : offset + limit]
|
|
769
|
+
from commitguard.controlplane.pagination import encode_cursor
|
|
770
|
+
|
|
771
|
+
return MatrixPage(
|
|
772
|
+
items=tuple(page),
|
|
773
|
+
total=len(items),
|
|
774
|
+
next_cursor=encode_cursor([offset + limit]) if len(items) > offset + limit else None,
|
|
775
|
+
computed_at=now,
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
# -- trends and snapshots --------------------------------------------- #
|
|
779
|
+
def snapshot_metrics(self) -> int:
|
|
780
|
+
"""Write today's metrics snapshot per organization (background job)."""
|
|
781
|
+
now = self._now()
|
|
782
|
+
day = now.date().isoformat()
|
|
783
|
+
written = 0
|
|
784
|
+
for row in self._store.query(
|
|
785
|
+
"SELECT DISTINCT account_id FROM installations WHERE state != 'deleted'"
|
|
786
|
+
):
|
|
787
|
+
account_id = int(row["account_id"])
|
|
788
|
+
existing = self._store.query(
|
|
789
|
+
"SELECT computed_at FROM security_metric_snapshots WHERE account_id = ? AND day = "
|
|
790
|
+
"?",
|
|
791
|
+
(account_id, day),
|
|
792
|
+
)
|
|
793
|
+
if existing and now - req_dt(existing[0]["computed_at"]) < timedelta(hours=1):
|
|
794
|
+
continue
|
|
795
|
+
document = self._system_metrics(account_id)
|
|
796
|
+
with self._store.transaction() as db:
|
|
797
|
+
db.execute(
|
|
798
|
+
"INSERT INTO security_metric_snapshots (account_id, day, computed_at, "
|
|
799
|
+
"document) "
|
|
800
|
+
"VALUES (?, ?, ?, ?) ON CONFLICT (account_id, day) DO UPDATE SET "
|
|
801
|
+
"computed_at = excluded.computed_at, document = excluded.document",
|
|
802
|
+
(account_id, day, ts(now), json.dumps(document, sort_keys=True)),
|
|
803
|
+
)
|
|
804
|
+
written += 1
|
|
805
|
+
return written
|
|
806
|
+
|
|
807
|
+
def _system_metrics(self, account_id: int) -> dict[str, int]:
|
|
808
|
+
"""Counts that do not depend on a session's visibility (organization totals)."""
|
|
809
|
+
now = self._now()
|
|
810
|
+
protection = {
|
|
811
|
+
str(r["branch_protection"]): int(r["n"])
|
|
812
|
+
for r in self._store.query(
|
|
813
|
+
"SELECT e.branch_protection, COUNT(*) AS n FROM enforcement_status e JOIN "
|
|
814
|
+
"installations i ON i.installation_id = e.installation_id WHERE i.account_id = ? "
|
|
815
|
+
"AND i.state = 'active' GROUP BY e.branch_protection",
|
|
816
|
+
(account_id,),
|
|
817
|
+
)
|
|
818
|
+
}
|
|
819
|
+
violations = self._store.query(
|
|
820
|
+
"SELECT SUM(v.severity = 'critical') AS critical, COUNT(*) AS open FROM violations v "
|
|
821
|
+
"JOIN installations i ON i.installation_id = v.installation_id WHERE i.account_id = ? "
|
|
822
|
+
"AND v.status = 'open'",
|
|
823
|
+
(account_id,),
|
|
824
|
+
)[0]
|
|
825
|
+
exceptions = self._store.query(
|
|
826
|
+
"SELECT COUNT(*) AS n FROM policy_exceptions WHERE account_id = ? AND status = "
|
|
827
|
+
"'active' "
|
|
828
|
+
"AND (permanent = 1 OR expires_at > ?)",
|
|
829
|
+
(account_id, ts(now)),
|
|
830
|
+
)[0]["n"]
|
|
831
|
+
drift = 0
|
|
832
|
+
for row in self._store.query(
|
|
833
|
+
"SELECT j.governance FROM scan_jobs j JOIN installations i ON i.installation_id = "
|
|
834
|
+
"j.installation_id WHERE i.account_id = ? AND j.governance IS NOT NULL AND "
|
|
835
|
+
"j.sequence = (SELECT MAX(n.sequence) FROM scan_jobs n WHERE n.installation_id = "
|
|
836
|
+
"j.installation_id AND n.repository_id = j.repository_id AND n.governance IS NOT NULL)",
|
|
837
|
+
(account_id,),
|
|
838
|
+
):
|
|
839
|
+
try:
|
|
840
|
+
effective = json.loads(str(row["governance"])).get("effective") or {}
|
|
841
|
+
except ValueError:
|
|
842
|
+
continue
|
|
843
|
+
if any(r.get("conflict") for r in effective.get("rules", [])):
|
|
844
|
+
drift += 1
|
|
845
|
+
return {
|
|
846
|
+
"protected_repositories": protection.get("required", 0),
|
|
847
|
+
"unprotected_repositories": protection.get("not_required", 0),
|
|
848
|
+
"open_violations": int(violations["open"] or 0),
|
|
849
|
+
"critical_open": int(violations["critical"] or 0),
|
|
850
|
+
"active_exceptions": int(exceptions),
|
|
851
|
+
"drift_repositories": drift,
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
def trends(self, principal: Principal, account_id: int, *, days: int) -> TrendsView:
|
|
855
|
+
require(principal, Permission.SECURITY_READ, account_id)
|
|
856
|
+
if not 1 <= days <= 365:
|
|
857
|
+
raise InputValidationError("days must be between 1 and 365", field="days")
|
|
858
|
+
now = self._now()
|
|
859
|
+
start = (now - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
|
|
860
|
+
history: dict[str, dict[str, int]] = {}
|
|
861
|
+
for row in self._store.query(
|
|
862
|
+
"SELECT date(j.completed_at, 'unixepoch') AS day, COUNT(*) AS scans, "
|
|
863
|
+
"SUM(j.state = 'failed') AS blocked, SUM(j.state = 'error') AS errors FROM scan_jobs j "
|
|
864
|
+
"JOIN installations i ON i.installation_id = j.installation_id WHERE i.account_id = ? "
|
|
865
|
+
"AND j.completed_at >= ? GROUP BY day",
|
|
866
|
+
(account_id, ts(start)),
|
|
867
|
+
):
|
|
868
|
+
history.setdefault(str(row["day"]), {}).update(
|
|
869
|
+
scans=int(row["scans"]),
|
|
870
|
+
blocked_scans=int(row["blocked"] or 0),
|
|
871
|
+
scan_errors=int(row["errors"] or 0),
|
|
872
|
+
)
|
|
873
|
+
for row in self._store.query(
|
|
874
|
+
"SELECT date(v.first_detected_at, 'unixepoch') AS day, COUNT(*) AS violations, "
|
|
875
|
+
"SUM(v.severity = 'critical') AS critical FROM violations v JOIN installations i ON "
|
|
876
|
+
"i.installation_id = v.installation_id WHERE i.account_id = ? "
|
|
877
|
+
"AND v.first_detected_at >= ? GROUP BY day",
|
|
878
|
+
(account_id, ts(start)),
|
|
879
|
+
):
|
|
880
|
+
history.setdefault(str(row["day"]), {}).update(
|
|
881
|
+
new_violations=int(row["violations"]),
|
|
882
|
+
new_critical=int(row["critical"] or 0),
|
|
883
|
+
)
|
|
884
|
+
snapshots = [
|
|
885
|
+
TrendPoint(day=str(r["day"]), values=json.loads(str(r["document"])))
|
|
886
|
+
for r in self._store.query(
|
|
887
|
+
"SELECT day, document FROM security_metric_snapshots WHERE account_id = ? "
|
|
888
|
+
"AND day >= ? ORDER BY day",
|
|
889
|
+
(account_id, start.date().isoformat()),
|
|
890
|
+
)
|
|
891
|
+
]
|
|
892
|
+
return TrendsView(
|
|
893
|
+
organization_id=account_id,
|
|
894
|
+
days=days,
|
|
895
|
+
history=tuple(
|
|
896
|
+
TrendPoint(day=day, values=values) for day, values in sorted(history.items())
|
|
897
|
+
),
|
|
898
|
+
snapshots=tuple(snapshots),
|
|
899
|
+
snapshot_note=(
|
|
900
|
+
"Protection, exceptions and drift over time come from daily snapshots taken by "
|
|
901
|
+
"CommitGuard; days before the first snapshot have no data."
|
|
902
|
+
),
|
|
903
|
+
computed_at=now,
|
|
904
|
+
)
|
|
905
|
+
|
|
906
|
+
# -- reports ---------------------------------------------------------- #
|
|
907
|
+
def report(
|
|
908
|
+
self, principal: Principal, account_id: int, *, kind: str, fmt: str
|
|
909
|
+
) -> tuple[str, bytes, str]:
|
|
910
|
+
"""(content type, body, filename). A point-in-time snapshot, audited."""
|
|
911
|
+
require(principal, Permission.SECURITY_READ, account_id)
|
|
912
|
+
if kind not in REPORT_KINDS:
|
|
913
|
+
raise InputValidationError(
|
|
914
|
+
f"report must be one of {', '.join(REPORT_KINDS)}", field="report"
|
|
915
|
+
)
|
|
916
|
+
if fmt not in ("json", "csv"):
|
|
917
|
+
raise InputValidationError("format must be json or csv", field="format")
|
|
918
|
+
if kind in ("policy_changes",) and not principal.can(Permission.AUDIT_READ, account_id):
|
|
919
|
+
raise NotFoundError()
|
|
920
|
+
now = self._now()
|
|
921
|
+
login, _ = self._organization_ref(principal, account_id)
|
|
922
|
+
rows, summary = self._report_rows(principal, account_id, kind)
|
|
923
|
+
document = {
|
|
924
|
+
"report": kind,
|
|
925
|
+
"organization": login,
|
|
926
|
+
"organization_id": account_id,
|
|
927
|
+
"generated_at": now.isoformat(),
|
|
928
|
+
"generated_by": principal.login,
|
|
929
|
+
"notice": NOT_A_CERTIFICATION,
|
|
930
|
+
"summary": summary,
|
|
931
|
+
"rows": rows,
|
|
932
|
+
}
|
|
933
|
+
with self._store.transaction() as db:
|
|
934
|
+
stored = self._store.insert_audit_event(
|
|
935
|
+
db,
|
|
936
|
+
self._audit.build(
|
|
937
|
+
AuditEventType.REPORT_EXPORTED,
|
|
938
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
939
|
+
account_id=account_id,
|
|
940
|
+
report=kind,
|
|
941
|
+
format=fmt,
|
|
942
|
+
rows=len(rows),
|
|
943
|
+
),
|
|
944
|
+
)
|
|
945
|
+
self._audit.log_stored(stored)
|
|
946
|
+
stamp = now.strftime("%Y%m%dT%H%M%SZ")
|
|
947
|
+
filename = f"commitguard-{login}-{kind}-{stamp}.{fmt}"
|
|
948
|
+
if fmt == "json":
|
|
949
|
+
body = json.dumps(document, indent=2, sort_keys=True, default=str).encode("utf-8")
|
|
950
|
+
return "application/json", body, filename
|
|
951
|
+
buffer = io.StringIO()
|
|
952
|
+
writer = csv.writer(buffer)
|
|
953
|
+
writer.writerow([f"# {NOT_A_CERTIFICATION}"])
|
|
954
|
+
writer.writerow([f"# organization={login} generated_at={now.isoformat()} report={kind}"])
|
|
955
|
+
columns = sorted({key for row in rows for key in row}) if rows else ["empty"]
|
|
956
|
+
writer.writerow(columns)
|
|
957
|
+
for row in rows:
|
|
958
|
+
writer.writerow([_csv_cell(row.get(column)) for column in columns])
|
|
959
|
+
return "text/csv; charset=utf-8", buffer.getvalue().encode("utf-8"), filename
|
|
960
|
+
|
|
961
|
+
def _report_rows(
|
|
962
|
+
self, principal: Principal, account_id: int, kind: str
|
|
963
|
+
) -> tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
964
|
+
if kind in ("compliance", "coverage"):
|
|
965
|
+
overview = self.overview(principal, account_id)
|
|
966
|
+
postures = self._postures(principal, account_id)
|
|
967
|
+
rows = [
|
|
968
|
+
{
|
|
969
|
+
"repository": p.full_name,
|
|
970
|
+
"posture": p.posture,
|
|
971
|
+
"reasons": "; ".join(p.posture_reasons),
|
|
972
|
+
"protection": p.protection.value,
|
|
973
|
+
"connection": p.connection,
|
|
974
|
+
"mode": p.mode.value,
|
|
975
|
+
"onboarding": p.onboarding,
|
|
976
|
+
"policy_state": p.policy_state,
|
|
977
|
+
"organization_policy_version": p.organization_policy_version,
|
|
978
|
+
"open_violations": p.open_violations,
|
|
979
|
+
"critical_open": p.critical_open,
|
|
980
|
+
"active_exceptions": p.active_exceptions,
|
|
981
|
+
"drift": p.drift,
|
|
982
|
+
"last_scan_at": p.last_scan_at.isoformat() if p.last_scan_at else None,
|
|
983
|
+
}
|
|
984
|
+
for p in sorted(postures, key=lambda p: p.full_name.casefold())
|
|
985
|
+
]
|
|
986
|
+
summary = {
|
|
987
|
+
"posture": overview.posture,
|
|
988
|
+
"repositories": overview.repositories,
|
|
989
|
+
"protected": overview.by_protection.get("protected", 0),
|
|
990
|
+
"at_risk": overview.by_posture.get("at_risk", 0),
|
|
991
|
+
"unprotected": overview.by_posture.get("unprotected", 0),
|
|
992
|
+
"critical_findings": overview.critical_open,
|
|
993
|
+
"active_exceptions": overview.active_exceptions,
|
|
994
|
+
"compliance": overview.compliance,
|
|
995
|
+
}
|
|
996
|
+
return rows, summary
|
|
997
|
+
if kind == "violations":
|
|
998
|
+
scope = principal.scope(Permission.VIOLATIONS_READ, account_id=account_id)
|
|
999
|
+
rows = [
|
|
1000
|
+
{
|
|
1001
|
+
"rule": r["rule_id"],
|
|
1002
|
+
"severity": r["severity"],
|
|
1003
|
+
"action": r["action"],
|
|
1004
|
+
"status": r["status"],
|
|
1005
|
+
"repository": f"{r['owner']}/{r['name']}",
|
|
1006
|
+
"open": int(r["n"]),
|
|
1007
|
+
}
|
|
1008
|
+
for r in self._store.query(
|
|
1009
|
+
"SELECT v.rule_id, v.severity, v.action, v.status, k.owner, k.name, COUNT(*) "
|
|
1010
|
+
"AS n FROM violations v JOIN known_repositories k ON k.installation_id = "
|
|
1011
|
+
"v.installation_id AND k.repository_id = v.repository_id WHERE "
|
|
1012
|
+
"v.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT 1 "
|
|
1013
|
+
"FROM session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id "
|
|
1014
|
+
"= v.installation_id AND sr.repository_id = v.repository_id) GROUP BY "
|
|
1015
|
+
"v.rule_id, v.severity, v.action, v.status, k.owner, k.name LIMIT ?",
|
|
1016
|
+
(scope.installations_json, scope.session_hash, REPORT_ROW_LIMIT),
|
|
1017
|
+
)
|
|
1018
|
+
]
|
|
1019
|
+
return rows, {"groups": len(rows), "truncated": len(rows) >= REPORT_ROW_LIMIT}
|
|
1020
|
+
if kind == "exceptions":
|
|
1021
|
+
rows = [
|
|
1022
|
+
{
|
|
1023
|
+
"id": r["exception_id"],
|
|
1024
|
+
"rule": r["rule_id"],
|
|
1025
|
+
"scope": r["scope_type"],
|
|
1026
|
+
"scope_id": r["scope_id"],
|
|
1027
|
+
"action": r["action"],
|
|
1028
|
+
"status": r["status"],
|
|
1029
|
+
"reason": r["reason"],
|
|
1030
|
+
"requested_by": r["requested_by_login"],
|
|
1031
|
+
"approved_by": r["decided_by_login"],
|
|
1032
|
+
"expires_at": req_dt(r["expires_at"]).isoformat() if r["expires_at"] else None,
|
|
1033
|
+
"permanent": bool(r["permanent"]),
|
|
1034
|
+
}
|
|
1035
|
+
for r in self._store.query(
|
|
1036
|
+
"SELECT * FROM policy_exceptions WHERE account_id = ? ORDER BY requested_at "
|
|
1037
|
+
"DESC LIMIT ?",
|
|
1038
|
+
(account_id, REPORT_ROW_LIMIT),
|
|
1039
|
+
)
|
|
1040
|
+
if r["scope_type"] != "repository"
|
|
1041
|
+
or int(r["scope_id"]) in self._visible(principal, account_id)
|
|
1042
|
+
]
|
|
1043
|
+
return rows, {"exceptions": len(rows), "truncated": len(rows) >= REPORT_ROW_LIMIT}
|
|
1044
|
+
if kind == "policy_changes":
|
|
1045
|
+
rows = [
|
|
1046
|
+
{
|
|
1047
|
+
"occurred_at": req_dt(r["occurred_at"]).isoformat(),
|
|
1048
|
+
"type": r["type"],
|
|
1049
|
+
"actor": r["actor_login"],
|
|
1050
|
+
"details": json.loads(str(r["document"])).get("data", {}),
|
|
1051
|
+
}
|
|
1052
|
+
for r in self._store.query(
|
|
1053
|
+
"SELECT occurred_at, type, actor_login, document FROM audit_events WHERE "
|
|
1054
|
+
"account_id = ? AND type IN ('organization_policy_changed', "
|
|
1055
|
+
"'organization_policy_rolled_back', 'policy_published', 'policy_rolled_back', "
|
|
1056
|
+
"'policy_emergency_published', 'policy_approved', 'policy_rejected', "
|
|
1057
|
+
"'organization_settings_changed', 'organization_rules_changed') "
|
|
1058
|
+
"ORDER BY occurred_at DESC LIMIT ?",
|
|
1059
|
+
(account_id, REPORT_ROW_LIMIT),
|
|
1060
|
+
)
|
|
1061
|
+
]
|
|
1062
|
+
return rows, {"changes": len(rows), "truncated": len(rows) >= REPORT_ROW_LIMIT}
|
|
1063
|
+
rows = [i.model_dump(mode="json") for i in self.installations(account_id)]
|
|
1064
|
+
return rows, {"installations": len(rows)}
|
|
1065
|
+
|
|
1066
|
+
def _visible(self, principal: Principal, account_id: int) -> set[int]:
|
|
1067
|
+
return visible_repository_ids(self._store, principal, account_id)
|
|
1068
|
+
|
|
1069
|
+
# -- search ----------------------------------------------------------- #
|
|
1070
|
+
def search(self, principal: Principal, account_id: int, q: str) -> list[SearchResult]:
|
|
1071
|
+
require(principal, Permission.ORGANIZATION_READ, account_id)
|
|
1072
|
+
needle = q.strip()
|
|
1073
|
+
if not 2 <= len(needle) <= 100:
|
|
1074
|
+
raise InputValidationError("q must be 2-100 characters", field="q")
|
|
1075
|
+
pattern = like_pattern(needle)
|
|
1076
|
+
results: list[SearchResult] = []
|
|
1077
|
+
visible = self._visible(principal, account_id)
|
|
1078
|
+
if principal.can(Permission.REPOSITORIES_READ, account_id):
|
|
1079
|
+
for r in self._store.query(
|
|
1080
|
+
"SELECT DISTINCT k.repository_id, k.owner, k.name FROM known_repositories k JOIN "
|
|
1081
|
+
"installations i ON i.installation_id = k.installation_id WHERE i.account_id = ? "
|
|
1082
|
+
"AND (k.owner || '/' || k.name) LIKE ? ESCAPE '\\' ORDER BY k.name LIMIT 30",
|
|
1083
|
+
(account_id, pattern),
|
|
1084
|
+
):
|
|
1085
|
+
if int(r["repository_id"]) in visible and len(results) < 10:
|
|
1086
|
+
results.append(
|
|
1087
|
+
SearchResult(
|
|
1088
|
+
kind="repository",
|
|
1089
|
+
id=str(r["repository_id"]),
|
|
1090
|
+
title=f"{r['owner']}/{r['name']}",
|
|
1091
|
+
detail="Repository",
|
|
1092
|
+
link=f"/repositories/{r['repository_id']}",
|
|
1093
|
+
)
|
|
1094
|
+
)
|
|
1095
|
+
for r in self._store.query(
|
|
1096
|
+
"SELECT group_id, name, description FROM repository_groups WHERE account_id = ? "
|
|
1097
|
+
"AND archived_at IS NULL AND name LIKE ? ESCAPE '\\' LIMIT 10",
|
|
1098
|
+
(account_id, pattern),
|
|
1099
|
+
):
|
|
1100
|
+
results.append(
|
|
1101
|
+
SearchResult(
|
|
1102
|
+
kind="group",
|
|
1103
|
+
id=str(r["group_id"]),
|
|
1104
|
+
title=str(r["name"]),
|
|
1105
|
+
detail=str(r["description"] or "Repository group"),
|
|
1106
|
+
link=f"/organization/groups/{r['group_id']}",
|
|
1107
|
+
)
|
|
1108
|
+
)
|
|
1109
|
+
if principal.can(Permission.POLICIES_READ, account_id):
|
|
1110
|
+
for r in self._store.query(
|
|
1111
|
+
"SELECT draft_id, title, state FROM policy_drafts WHERE account_id = ? "
|
|
1112
|
+
"AND title LIKE ? ESCAPE '\\' ORDER BY updated_at DESC LIMIT 10",
|
|
1113
|
+
(account_id, pattern),
|
|
1114
|
+
):
|
|
1115
|
+
results.append(
|
|
1116
|
+
SearchResult(
|
|
1117
|
+
kind="policy",
|
|
1118
|
+
id=str(r["draft_id"]),
|
|
1119
|
+
title=str(r["title"]),
|
|
1120
|
+
detail=f"Policy draft ({r['state']})",
|
|
1121
|
+
link=f"/organization/policies/drafts/{r['draft_id']}",
|
|
1122
|
+
)
|
|
1123
|
+
)
|
|
1124
|
+
if principal.can(Permission.RULES_READ, account_id):
|
|
1125
|
+
lowered = needle.casefold()
|
|
1126
|
+
for entry in CATALOG:
|
|
1127
|
+
if lowered in entry.rule_id or lowered in entry.name.casefold():
|
|
1128
|
+
results.append(
|
|
1129
|
+
SearchResult(
|
|
1130
|
+
kind="rule",
|
|
1131
|
+
id=entry.rule_id,
|
|
1132
|
+
title=entry.name,
|
|
1133
|
+
detail=f"Rule {entry.rule_id} ({entry.severity.value})",
|
|
1134
|
+
link=f"/rules/{entry.rule_id}",
|
|
1135
|
+
)
|
|
1136
|
+
)
|
|
1137
|
+
if principal.can(Permission.EXCEPTIONS_READ, account_id):
|
|
1138
|
+
for r in self._store.query(
|
|
1139
|
+
"SELECT exception_id, rule_id, scope_type, scope_id, status, reason FROM "
|
|
1140
|
+
"policy_exceptions WHERE account_id = ? AND (rule_id LIKE ? ESCAPE '\\' OR reason "
|
|
1141
|
+
"LIKE ? ESCAPE '\\') ORDER BY requested_at DESC LIMIT 20",
|
|
1142
|
+
(account_id, pattern, pattern),
|
|
1143
|
+
):
|
|
1144
|
+
if r["scope_type"] == "repository" and int(r["scope_id"]) not in visible:
|
|
1145
|
+
continue
|
|
1146
|
+
results.append(
|
|
1147
|
+
SearchResult(
|
|
1148
|
+
kind="exception",
|
|
1149
|
+
id=str(r["exception_id"]),
|
|
1150
|
+
title=f"Exception: {r['rule_id']} ({r['status']})",
|
|
1151
|
+
detail=str(r["reason"])[:120],
|
|
1152
|
+
link=f"/organization/exceptions/{r['exception_id']}",
|
|
1153
|
+
)
|
|
1154
|
+
)
|
|
1155
|
+
if principal.can(Permission.VIOLATIONS_READ, account_id):
|
|
1156
|
+
scope = principal.scope(Permission.VIOLATIONS_READ, account_id=account_id)
|
|
1157
|
+
for r in self._store.query(
|
|
1158
|
+
"SELECT v.violation_id, v.rule_id, v.title, v.commit_sha, v.status FROM violations "
|
|
1159
|
+
"v WHERE v.installation_id IN (SELECT value FROM json_each(?)) AND EXISTS (SELECT "
|
|
1160
|
+
"1 FROM session_repositories sr WHERE sr.session_hash = ? AND sr.installation_id = "
|
|
1161
|
+
"v.installation_id AND sr.repository_id = v.repository_id) AND (v.rule_id LIKE ? "
|
|
1162
|
+
"ESCAPE '\\' OR v.title LIKE ? ESCAPE '\\' OR v.commit_sha LIKE ?) "
|
|
1163
|
+
"ORDER BY v.last_detected_at DESC LIMIT 10",
|
|
1164
|
+
(
|
|
1165
|
+
scope.installations_json,
|
|
1166
|
+
scope.session_hash,
|
|
1167
|
+
pattern,
|
|
1168
|
+
pattern,
|
|
1169
|
+
f"{needle.lower()}%" if needle.isalnum() else "\x00",
|
|
1170
|
+
),
|
|
1171
|
+
):
|
|
1172
|
+
results.append(
|
|
1173
|
+
SearchResult(
|
|
1174
|
+
kind="finding",
|
|
1175
|
+
id=str(r["violation_id"]),
|
|
1176
|
+
title=str(r["title"]),
|
|
1177
|
+
detail=f"{r['rule_id']} · {r['status']} · {str(r['commit_sha'])[:12]}",
|
|
1178
|
+
link=f"/violations/{r['violation_id']}",
|
|
1179
|
+
)
|
|
1180
|
+
)
|
|
1181
|
+
return results[:50]
|
|
1182
|
+
|
|
1183
|
+
# -- acknowledgement -------------------------------------------------- #
|
|
1184
|
+
def acknowledge_event(
|
|
1185
|
+
self, principal: Principal, account_id: int, event_id: str, note: object
|
|
1186
|
+
) -> dict[str, Any]:
|
|
1187
|
+
"""Record that someone saw a critical security event. It does not resolve anything."""
|
|
1188
|
+
require(principal, Permission.VIOLATIONS_MANAGE, account_id)
|
|
1189
|
+
if not is_hex_id(event_id):
|
|
1190
|
+
raise NotFoundError()
|
|
1191
|
+
rows = self._store.query(
|
|
1192
|
+
"SELECT event_id, severity, type, title FROM notification_events WHERE event_id = ? "
|
|
1193
|
+
"AND account_id = ?",
|
|
1194
|
+
(event_id, account_id),
|
|
1195
|
+
)
|
|
1196
|
+
if not rows:
|
|
1197
|
+
raise NotFoundError()
|
|
1198
|
+
if rows[0]["severity"] not in ("critical", "high"):
|
|
1199
|
+
raise ConflictError("Only critical and high security events are acknowledged.")
|
|
1200
|
+
note_text = text(note, "note", limit=500)
|
|
1201
|
+
now = self._now()
|
|
1202
|
+
with self._store.transaction() as db:
|
|
1203
|
+
cursor = db.execute(
|
|
1204
|
+
"INSERT OR IGNORE INTO notification_acknowledgements (event_id, account_id, "
|
|
1205
|
+
"acknowledged_by_id, acknowledged_by_login, acknowledged_at, note) "
|
|
1206
|
+
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
1207
|
+
(event_id, account_id, principal.user_id, principal.login, ts(now), note_text),
|
|
1208
|
+
)
|
|
1209
|
+
if cursor.rowcount != 1:
|
|
1210
|
+
raise ConflictError("This event was already acknowledged.")
|
|
1211
|
+
stored = self._store.insert_audit_event(
|
|
1212
|
+
db,
|
|
1213
|
+
self._audit.build(
|
|
1214
|
+
AuditEventType.SECURITY_EVENT_ACKNOWLEDGED,
|
|
1215
|
+
actor=Actor.user(principal.user_id, principal.login),
|
|
1216
|
+
account_id=account_id,
|
|
1217
|
+
notification_event=event_id,
|
|
1218
|
+
notification_type=str(rows[0]["type"]),
|
|
1219
|
+
note=note_text,
|
|
1220
|
+
),
|
|
1221
|
+
)
|
|
1222
|
+
self._audit.log_stored(stored)
|
|
1223
|
+
return {
|
|
1224
|
+
"event_id": event_id,
|
|
1225
|
+
"acknowledged_by": principal.login,
|
|
1226
|
+
"acknowledged_at": now.isoformat(),
|
|
1227
|
+
"meaning": "An administrator has seen this event. It does not resolve any violation.",
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
def security_events(self, principal: Principal, account_id: int) -> list[dict[str, Any]]:
|
|
1231
|
+
"""Recent critical and high organization events with their acknowledgement state."""
|
|
1232
|
+
require(principal, Permission.SECURITY_READ, account_id)
|
|
1233
|
+
rows = self._store.query(
|
|
1234
|
+
"SELECT e.event_id, e.type, e.severity, e.title, e.body, e.repository_id, "
|
|
1235
|
+
"e.resource_type, e.resource_id, e.last_occurred_at, e.occurrences, "
|
|
1236
|
+
"a.acknowledged_by_login, a.acknowledged_at FROM "
|
|
1237
|
+
"notification_events e LEFT JOIN notification_acknowledgements a ON a.event_id = "
|
|
1238
|
+
"e.event_id WHERE e.account_id = ? AND e.severity IN ('critical', 'high') "
|
|
1239
|
+
"ORDER BY e.last_occurred_at DESC LIMIT 50",
|
|
1240
|
+
(account_id,),
|
|
1241
|
+
)
|
|
1242
|
+
visible = self._visible(principal, account_id)
|
|
1243
|
+
return [
|
|
1244
|
+
{
|
|
1245
|
+
"id": str(r["event_id"]),
|
|
1246
|
+
"type": str(r["type"]),
|
|
1247
|
+
"severity": str(r["severity"]),
|
|
1248
|
+
"repository_id": r["repository_id"],
|
|
1249
|
+
"resource_type": str(r["resource_type"]),
|
|
1250
|
+
"resource_id": str(r["resource_id"]),
|
|
1251
|
+
"title": str(r["title"]),
|
|
1252
|
+
"body": str(r["body"]),
|
|
1253
|
+
"occurrences": int(r["occurrences"]),
|
|
1254
|
+
"last_occurred_at": req_dt(r["last_occurred_at"]).isoformat(),
|
|
1255
|
+
"acknowledged_by": r["acknowledged_by_login"],
|
|
1256
|
+
"acknowledged_at": req_dt(r["acknowledged_at"]).isoformat()
|
|
1257
|
+
if r["acknowledged_at"]
|
|
1258
|
+
else None,
|
|
1259
|
+
}
|
|
1260
|
+
for r in rows
|
|
1261
|
+
if r["repository_id"] is None or int(r["repository_id"]) in visible
|
|
1262
|
+
]
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def _csv_cell(value: object) -> str:
|
|
1266
|
+
"""CSV cells that a spreadsheet will not execute (formula injection)."""
|
|
1267
|
+
if value is None:
|
|
1268
|
+
return ""
|
|
1269
|
+
cell = json.dumps(value, sort_keys=True) if isinstance(value, dict | list) else str(value)
|
|
1270
|
+
if cell[:1] in ("=", "+", "-", "@", "\t", "\r"):
|
|
1271
|
+
cell = "'" + cell
|
|
1272
|
+
return cell
|