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,632 @@
|
|
|
1
|
+
"""GovernanceResolver: which governance inputs apply to a repository, cached and propagated.
|
|
2
|
+
|
|
3
|
+
The resolver *collects* what the organization layer decided for one repository
|
|
4
|
+
and hands it to the pure :func:`commitguard.policies.governance.resolve_policy`
|
|
5
|
+
(which builds the policy set) - the policy engine then evaluates findings with
|
|
6
|
+
that set. Nothing here evaluates a finding.
|
|
7
|
+
|
|
8
|
+
Inputs, in the order they are layered::
|
|
9
|
+
|
|
10
|
+
service policy operator file (mandatory)
|
|
11
|
+
security baseline organization settings (mandatory)
|
|
12
|
+
organization policy the version that applies to this repository:
|
|
13
|
+
- a staged rollout in progress: the new version for
|
|
14
|
+
enrolled repositories, the previous one for the rest
|
|
15
|
+
- otherwise the newest published version
|
|
16
|
+
repository group policies every non-archived group the repository belongs to
|
|
17
|
+
(rollout-aware per group)
|
|
18
|
+
repository policy the newest published version for this repository
|
|
19
|
+
approved exceptions active, unexpired, scoped to the organization, one of
|
|
20
|
+
its groups, or the repository
|
|
21
|
+
mode monitor / enforce
|
|
22
|
+
|
|
23
|
+
Cache and propagation
|
|
24
|
+
=====================
|
|
25
|
+
|
|
26
|
+
Resolution runs inside one database transaction (a consistent snapshot, and
|
|
27
|
+
SQLite serialises writers), and its result is stored in
|
|
28
|
+
``repository_effective_policies`` as ``up_to_date``. Every change that affects a
|
|
29
|
+
repository marks its row ``stale`` in the change's own transaction
|
|
30
|
+
(:mod:`commitguard.governance.cache`), so a scan uses a cached row only when it
|
|
31
|
+
is current - otherwise it resolves again. Rows also carry ``valid_until``: the
|
|
32
|
+
earliest expiry of an exception they include, after which they are resolved
|
|
33
|
+
again even before the expiry worker runs.
|
|
34
|
+
|
|
35
|
+
The propagation job (:meth:`GovernanceResolver.propagate`) resolves stale rows
|
|
36
|
+
and rows of repositories never resolved, in bounded batches. A repository whose
|
|
37
|
+
resolution fails is ``error``: administrators are notified and the dashboard
|
|
38
|
+
shows it, and a scan of that repository resolves directly - if that also fails,
|
|
39
|
+
the scan fails closed. Propagation status is reported as counts; a policy change
|
|
40
|
+
is never shown as propagated while any affected repository is stale, syncing or
|
|
41
|
+
in error.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
import json
|
|
45
|
+
import sqlite3
|
|
46
|
+
from collections.abc import Callable
|
|
47
|
+
from datetime import UTC, datetime
|
|
48
|
+
|
|
49
|
+
from pydantic import BaseModel, ConfigDict
|
|
50
|
+
|
|
51
|
+
from commitguard.audit.models import SYSTEM_ACTOR, AuditEventType
|
|
52
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
53
|
+
from commitguard.controlplane.errors import NotFoundError
|
|
54
|
+
from commitguard.controlplane.policies import (
|
|
55
|
+
ORGANIZATION_TARGET,
|
|
56
|
+
OrganizationPolicyService,
|
|
57
|
+
PolicyTarget,
|
|
58
|
+
PublishedPolicy,
|
|
59
|
+
parse_document,
|
|
60
|
+
)
|
|
61
|
+
from commitguard.core.decision import Action
|
|
62
|
+
from commitguard.core.result import Severity
|
|
63
|
+
from commitguard.github.storage import SqliteStateStore
|
|
64
|
+
from commitguard.governance.cache import invalidate_repositories, invalidate_scope
|
|
65
|
+
from commitguard.governance.common import (
|
|
66
|
+
account_repositories,
|
|
67
|
+
account_repository,
|
|
68
|
+
dt,
|
|
69
|
+
require,
|
|
70
|
+
ts,
|
|
71
|
+
visible_repository_ids,
|
|
72
|
+
)
|
|
73
|
+
from commitguard.governance.exceptions import active_grants
|
|
74
|
+
from commitguard.governance.inventory import governance_state
|
|
75
|
+
from commitguard.governance.settings import OrganizationSettings, load_settings
|
|
76
|
+
from commitguard.notifications.deduplication import domain_key
|
|
77
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
78
|
+
from commitguard.notifications.outbox import emit
|
|
79
|
+
from commitguard.observability.logging import get_logger
|
|
80
|
+
from commitguard.policies.defaults import DEFAULT_POLICIES
|
|
81
|
+
from commitguard.policies.governance import (
|
|
82
|
+
EffectivePolicy,
|
|
83
|
+
Enforcement,
|
|
84
|
+
GovernanceInputs,
|
|
85
|
+
PolicyLayer,
|
|
86
|
+
PolicyLevel,
|
|
87
|
+
RepositoryMode,
|
|
88
|
+
RuleRequirement,
|
|
89
|
+
resolve_policy,
|
|
90
|
+
)
|
|
91
|
+
from commitguard.security.hashing import sha256_hex
|
|
92
|
+
from commitguard.services.audit import AuditService
|
|
93
|
+
|
|
94
|
+
log = get_logger(__name__)
|
|
95
|
+
|
|
96
|
+
PROPAGATION_BATCH = 200
|
|
97
|
+
MAX_PROPAGATION_ATTEMPTS = 5
|
|
98
|
+
PROPAGATION_STATES = ("up_to_date", "stale", "syncing", "error", "pending")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class GovernanceVersions(BaseModel):
|
|
102
|
+
"""Which version of each governance source applied (recorded with scans)."""
|
|
103
|
+
|
|
104
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
105
|
+
|
|
106
|
+
organization_policy: int | None = None
|
|
107
|
+
settings: int | None = None
|
|
108
|
+
groups: dict[str, int] = {}
|
|
109
|
+
repository_policy: int | None = None
|
|
110
|
+
rollouts: dict[str, int] = {} # rollout ID -> version applied through it
|
|
111
|
+
exceptions: tuple[str, ...] = ()
|
|
112
|
+
organization_rules: int | None = None # organization rules version, when any
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class ResolvedGovernance(BaseModel):
|
|
116
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
117
|
+
|
|
118
|
+
account_id: int
|
|
119
|
+
repository_id: int
|
|
120
|
+
inputs: GovernanceInputs
|
|
121
|
+
versions: GovernanceVersions
|
|
122
|
+
valid_until: datetime | None
|
|
123
|
+
resolved_at: datetime
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def fingerprint(self) -> str:
|
|
127
|
+
"""Everything that can change a decision: policy inputs and organization rules."""
|
|
128
|
+
rules = self.versions.organization_rules or 0
|
|
129
|
+
return sha256_hex(f"{self.inputs.fingerprint}:rules-v{rules}".encode())
|
|
130
|
+
|
|
131
|
+
def document(self) -> str:
|
|
132
|
+
return self.model_dump_json()
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class PropagationStatus(BaseModel):
|
|
136
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
137
|
+
|
|
138
|
+
organization_id: int
|
|
139
|
+
repositories: int
|
|
140
|
+
up_to_date: int
|
|
141
|
+
stale: int
|
|
142
|
+
syncing: int
|
|
143
|
+
error: int
|
|
144
|
+
pending: int # never resolved yet
|
|
145
|
+
complete: bool # every repository up to date
|
|
146
|
+
failing: tuple[dict[str, object], ...] # repositories in error (visible ones)
|
|
147
|
+
checked_at: datetime
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class EffectivePolicyView(BaseModel):
|
|
151
|
+
"""A repository's effective policy with provenance, as the dashboard shows it."""
|
|
152
|
+
|
|
153
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
154
|
+
|
|
155
|
+
organization_id: int
|
|
156
|
+
repository_id: int
|
|
157
|
+
full_name: str
|
|
158
|
+
mode: RepositoryMode
|
|
159
|
+
effective: EffectivePolicy
|
|
160
|
+
versions: GovernanceVersions
|
|
161
|
+
propagation: str
|
|
162
|
+
resolved_at: datetime
|
|
163
|
+
#: From the latest completed scan: conflicts involving the repository's own
|
|
164
|
+
#: .commitguard.yaml, which is only known at scan time.
|
|
165
|
+
last_scan_id: str | None
|
|
166
|
+
last_scan_completed_at: datetime | None
|
|
167
|
+
last_scan_effective: EffectivePolicy | None
|
|
168
|
+
last_scan_used_current_policy: bool | None
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _service_layer(policies: OrganizationPolicyService) -> PolicyLayer | None:
|
|
172
|
+
service = policies.service_policy
|
|
173
|
+
if service is None or not service.config.policies:
|
|
174
|
+
return None
|
|
175
|
+
rules = {
|
|
176
|
+
rule: RuleRequirement(action=override.action or DEFAULT_POLICIES[rule].action)
|
|
177
|
+
for rule, override in service.config.policies.items()
|
|
178
|
+
if (override.action or DEFAULT_POLICIES[rule].action) is not Action.ALLOW
|
|
179
|
+
}
|
|
180
|
+
return PolicyLayer(
|
|
181
|
+
level=PolicyLevel.SERVICE,
|
|
182
|
+
label=f"mandatory policy ({service.description})"[:200],
|
|
183
|
+
rules=rules,
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _row_version(row: sqlite3.Row | None) -> int:
|
|
188
|
+
return int(row["latest"] or 0) if row is not None else 0
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class GovernanceResolver:
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
store: SqliteStateStore,
|
|
195
|
+
policies: OrganizationPolicyService,
|
|
196
|
+
audit: AuditService,
|
|
197
|
+
*,
|
|
198
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
199
|
+
) -> None:
|
|
200
|
+
self._store = store
|
|
201
|
+
self._policies = policies
|
|
202
|
+
self._audit = audit
|
|
203
|
+
self._now = now
|
|
204
|
+
policies.add_publish_hook(self.on_policy_published)
|
|
205
|
+
|
|
206
|
+
# -- invalidation hooks ----------------------------------------------- #
|
|
207
|
+
def on_policy_published(self, db: sqlite3.Connection, published: PublishedPolicy) -> None:
|
|
208
|
+
invalidate_scope(
|
|
209
|
+
db,
|
|
210
|
+
published.account_id,
|
|
211
|
+
published.target.type.value,
|
|
212
|
+
published.target.id,
|
|
213
|
+
published.now,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
def on_settings_saved(
|
|
217
|
+
self, db: sqlite3.Connection, account_id: int, _settings: OrganizationSettings
|
|
218
|
+
) -> None:
|
|
219
|
+
invalidate_repositories(db, account_id, None, self._now())
|
|
220
|
+
|
|
221
|
+
# -- resolution ------------------------------------------------------- #
|
|
222
|
+
def _version_for(
|
|
223
|
+
self,
|
|
224
|
+
db: sqlite3.Connection,
|
|
225
|
+
account_id: int,
|
|
226
|
+
target: PolicyTarget,
|
|
227
|
+
repository_id: int,
|
|
228
|
+
rollouts: dict[str, int],
|
|
229
|
+
) -> int:
|
|
230
|
+
if target.scoped:
|
|
231
|
+
latest = _row_version(
|
|
232
|
+
db.execute(
|
|
233
|
+
"SELECT MAX(version) AS latest FROM scoped_policy_versions WHERE "
|
|
234
|
+
"account_id = ? AND target_type = ? AND target_id = ?",
|
|
235
|
+
(account_id, target.type.value, target.id),
|
|
236
|
+
).fetchone()
|
|
237
|
+
)
|
|
238
|
+
else:
|
|
239
|
+
latest = _row_version(
|
|
240
|
+
db.execute(
|
|
241
|
+
"SELECT MAX(version) AS latest FROM organization_policy_versions "
|
|
242
|
+
"WHERE account_id = ?",
|
|
243
|
+
(account_id,),
|
|
244
|
+
).fetchone()
|
|
245
|
+
)
|
|
246
|
+
rollout = db.execute(
|
|
247
|
+
"SELECT rollout_id, from_version, to_version FROM policy_rollouts WHERE "
|
|
248
|
+
"account_id = ? AND target_type = ? AND target_id = ? AND state IN "
|
|
249
|
+
"('pilot', 'rollout', 'paused')",
|
|
250
|
+
(account_id, target.type.value, target.id),
|
|
251
|
+
).fetchone()
|
|
252
|
+
if rollout is None or int(rollout["to_version"]) != latest:
|
|
253
|
+
return latest
|
|
254
|
+
enrolled = db.execute(
|
|
255
|
+
"SELECT 1 FROM policy_rollout_repositories WHERE rollout_id = ? AND repository_id = ?",
|
|
256
|
+
(rollout["rollout_id"], repository_id),
|
|
257
|
+
).fetchone()
|
|
258
|
+
version = latest if enrolled else int(rollout["from_version"])
|
|
259
|
+
rollouts[str(rollout["rollout_id"])] = version
|
|
260
|
+
return version
|
|
261
|
+
|
|
262
|
+
def _layer(
|
|
263
|
+
self,
|
|
264
|
+
account_id: int,
|
|
265
|
+
target: PolicyTarget,
|
|
266
|
+
version: int,
|
|
267
|
+
level: PolicyLevel,
|
|
268
|
+
label: str,
|
|
269
|
+
) -> PolicyLayer | None:
|
|
270
|
+
if version <= 0:
|
|
271
|
+
return None
|
|
272
|
+
stored = self._policies.version(account_id, version, target)
|
|
273
|
+
if stored is None:
|
|
274
|
+
raise RuntimeError(f"policy version {version} is missing")
|
|
275
|
+
floors, defaults = parse_document(stored.document or "{}")
|
|
276
|
+
rules = {
|
|
277
|
+
rule: RuleRequirement(action=action, enforcement=Enforcement.DEFAULT)
|
|
278
|
+
for rule, action in defaults.items()
|
|
279
|
+
}
|
|
280
|
+
rules.update({rule: RuleRequirement(action=action) for rule, action in floors.items()})
|
|
281
|
+
if not rules:
|
|
282
|
+
return None
|
|
283
|
+
return PolicyLayer(
|
|
284
|
+
level=level,
|
|
285
|
+
source_id=target.id,
|
|
286
|
+
label=f"{label} v{version}"[:200],
|
|
287
|
+
version=version,
|
|
288
|
+
rules=rules,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
def resolve_in(
|
|
292
|
+
self, db: sqlite3.Connection, account_id: int, repository_id: int
|
|
293
|
+
) -> ResolvedGovernance:
|
|
294
|
+
"""Resolve from the database (no cache), inside the caller's transaction."""
|
|
295
|
+
now = self._now()
|
|
296
|
+
layers: list[PolicyLayer] = []
|
|
297
|
+
service = _service_layer(self._policies)
|
|
298
|
+
if service is not None:
|
|
299
|
+
layers.append(service)
|
|
300
|
+
stored_settings = load_settings(db, account_id)
|
|
301
|
+
baseline = stored_settings.settings.security_baseline
|
|
302
|
+
if baseline:
|
|
303
|
+
layers.append(
|
|
304
|
+
PolicyLayer(
|
|
305
|
+
level=PolicyLevel.ORGANIZATION,
|
|
306
|
+
source_id="baseline",
|
|
307
|
+
label=f"security baseline (settings v{stored_settings.version})",
|
|
308
|
+
version=stored_settings.version or None,
|
|
309
|
+
rules={rule: RuleRequirement(action=a) for rule, a in baseline.items()},
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
rollouts: dict[str, int] = {}
|
|
313
|
+
organization_version = self._version_for(
|
|
314
|
+
db, account_id, ORGANIZATION_TARGET, repository_id, rollouts
|
|
315
|
+
)
|
|
316
|
+
organization = self._layer(
|
|
317
|
+
account_id,
|
|
318
|
+
ORGANIZATION_TARGET,
|
|
319
|
+
organization_version,
|
|
320
|
+
PolicyLevel.ORGANIZATION,
|
|
321
|
+
"organization policy",
|
|
322
|
+
)
|
|
323
|
+
if organization is not None:
|
|
324
|
+
layers.append(organization)
|
|
325
|
+
groups: dict[str, int] = {}
|
|
326
|
+
group_rows = db.execute(
|
|
327
|
+
"SELECT g.group_id, g.name FROM repository_group_members m JOIN repository_groups g "
|
|
328
|
+
"ON g.group_id = m.group_id WHERE m.account_id = ? AND m.repository_id = ? "
|
|
329
|
+
"AND g.archived_at IS NULL ORDER BY g.name_key LIMIT 50",
|
|
330
|
+
(account_id, repository_id),
|
|
331
|
+
).fetchall()
|
|
332
|
+
for group in group_rows:
|
|
333
|
+
target = PolicyTarget.group(str(group["group_id"]))
|
|
334
|
+
version = self._version_for(db, account_id, target, repository_id, rollouts)
|
|
335
|
+
groups[str(group["group_id"])] = version
|
|
336
|
+
layer = self._layer(
|
|
337
|
+
account_id, target, version, PolicyLevel.GROUP, f"group {group['name']} policy"
|
|
338
|
+
)
|
|
339
|
+
if layer is not None:
|
|
340
|
+
layers.append(layer)
|
|
341
|
+
repository_target = PolicyTarget.repository(repository_id)
|
|
342
|
+
repository_version = self._version_for(
|
|
343
|
+
db, account_id, repository_target, repository_id, rollouts
|
|
344
|
+
)
|
|
345
|
+
repository_layer = self._layer(
|
|
346
|
+
account_id,
|
|
347
|
+
repository_target,
|
|
348
|
+
repository_version,
|
|
349
|
+
PolicyLevel.REPOSITORY_POLICY,
|
|
350
|
+
"repository policy",
|
|
351
|
+
)
|
|
352
|
+
if repository_layer is not None:
|
|
353
|
+
layers.append(repository_layer)
|
|
354
|
+
grants = active_grants(
|
|
355
|
+
db, account_id, repository_id, [str(g["group_id"]) for g in group_rows], now
|
|
356
|
+
)
|
|
357
|
+
mode = governance_state(db, account_id, repository_id).mode
|
|
358
|
+
rules_row = db.execute(
|
|
359
|
+
"SELECT MAX(version) AS latest FROM organization_rule_versions WHERE account_id = ?",
|
|
360
|
+
(account_id,),
|
|
361
|
+
).fetchone()
|
|
362
|
+
expiries = [g.expires_at for g in grants if g.expires_at is not None]
|
|
363
|
+
return ResolvedGovernance(
|
|
364
|
+
account_id=account_id,
|
|
365
|
+
repository_id=repository_id,
|
|
366
|
+
inputs=GovernanceInputs(layers=tuple(layers), exceptions=tuple(grants), mode=mode),
|
|
367
|
+
versions=GovernanceVersions(
|
|
368
|
+
organization_policy=organization_version or None,
|
|
369
|
+
settings=stored_settings.version or None,
|
|
370
|
+
groups=groups,
|
|
371
|
+
repository_policy=repository_version or None,
|
|
372
|
+
rollouts=rollouts,
|
|
373
|
+
exceptions=tuple(g.exception_id for g in grants),
|
|
374
|
+
organization_rules=_row_version(rules_row) or None,
|
|
375
|
+
),
|
|
376
|
+
valid_until=min(expiries) if expiries else None,
|
|
377
|
+
resolved_at=now,
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def _store_resolution(self, db: sqlite3.Connection, resolved: ResolvedGovernance) -> None:
|
|
381
|
+
db.execute(
|
|
382
|
+
"INSERT INTO repository_effective_policies (account_id, repository_id, state, "
|
|
383
|
+
"fingerprint, document, computed_at, valid_until, attempts, error) "
|
|
384
|
+
"VALUES (?, ?, 'up_to_date', ?, ?, ?, ?, 0, NULL) "
|
|
385
|
+
"ON CONFLICT (account_id, repository_id) DO UPDATE SET state = 'up_to_date', "
|
|
386
|
+
"fingerprint = excluded.fingerprint, document = excluded.document, "
|
|
387
|
+
"computed_at = excluded.computed_at, valid_until = excluded.valid_until, "
|
|
388
|
+
"attempts = 0, error = NULL",
|
|
389
|
+
(
|
|
390
|
+
resolved.account_id,
|
|
391
|
+
resolved.repository_id,
|
|
392
|
+
resolved.fingerprint,
|
|
393
|
+
resolved.document(),
|
|
394
|
+
ts(resolved.resolved_at),
|
|
395
|
+
ts(resolved.valid_until) if resolved.valid_until else None,
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
def for_repository(self, account_id: int, repository_id: int) -> ResolvedGovernance:
|
|
400
|
+
"""Current governance for a repository: the cached row if current, else resolved."""
|
|
401
|
+
now = self._now()
|
|
402
|
+
rows = self._store.query(
|
|
403
|
+
"SELECT state, document, valid_until FROM repository_effective_policies "
|
|
404
|
+
"WHERE account_id = ? AND repository_id = ?",
|
|
405
|
+
(account_id, repository_id),
|
|
406
|
+
)
|
|
407
|
+
if rows and rows[0]["state"] == "up_to_date" and rows[0]["document"]:
|
|
408
|
+
valid_until = dt(rows[0]["valid_until"])
|
|
409
|
+
if valid_until is None or valid_until > now:
|
|
410
|
+
try:
|
|
411
|
+
return ResolvedGovernance.model_validate_json(str(rows[0]["document"]))
|
|
412
|
+
except ValueError:
|
|
413
|
+
log.warning("effective_policy_cache_unreadable", repository_id=repository_id)
|
|
414
|
+
with self._store.transaction() as db:
|
|
415
|
+
resolved = self.resolve_in(db, account_id, repository_id)
|
|
416
|
+
self._store_resolution(db, resolved)
|
|
417
|
+
return resolved
|
|
418
|
+
|
|
419
|
+
def for_scan(self, installation_id: int, repository_id: int) -> ResolvedGovernance | None:
|
|
420
|
+
"""Governance for a scan job; None when the installation has no organization record."""
|
|
421
|
+
rows = self._store.query(
|
|
422
|
+
"SELECT account_id FROM installations WHERE installation_id = ?", (installation_id,)
|
|
423
|
+
)
|
|
424
|
+
if not rows:
|
|
425
|
+
return None
|
|
426
|
+
return self.for_repository(int(rows[0]["account_id"]), repository_id)
|
|
427
|
+
|
|
428
|
+
# -- propagation ------------------------------------------------------ #
|
|
429
|
+
def propagate(self, limit: int = PROPAGATION_BATCH) -> dict[str, int]:
|
|
430
|
+
"""Resolve stale, failed and never-resolved repositories (bounded)."""
|
|
431
|
+
pending = self._store.query(
|
|
432
|
+
"SELECT account_id, repository_id FROM repository_effective_policies "
|
|
433
|
+
"WHERE state IN ('stale', 'error', 'syncing') AND attempts < ? "
|
|
434
|
+
"ORDER BY invalidated_at LIMIT ?",
|
|
435
|
+
(MAX_PROPAGATION_ATTEMPTS, int(limit)),
|
|
436
|
+
)
|
|
437
|
+
work = [(int(r["account_id"]), int(r["repository_id"])) for r in pending]
|
|
438
|
+
if len(work) < limit:
|
|
439
|
+
missing = self._store.query(
|
|
440
|
+
"SELECT DISTINCT i.account_id, k.repository_id FROM known_repositories k "
|
|
441
|
+
"JOIN installations i ON i.installation_id = k.installation_id "
|
|
442
|
+
"WHERE i.state != 'deleted' AND NOT EXISTS (SELECT 1 FROM "
|
|
443
|
+
"repository_effective_policies e WHERE e.account_id = i.account_id AND "
|
|
444
|
+
"e.repository_id = k.repository_id) LIMIT ?",
|
|
445
|
+
(int(limit) - len(work),),
|
|
446
|
+
)
|
|
447
|
+
work += [(int(r["account_id"]), int(r["repository_id"])) for r in missing]
|
|
448
|
+
counts = {"resolved": 0, "failed": 0}
|
|
449
|
+
failed_accounts: dict[int, list[int]] = {}
|
|
450
|
+
for account_id, repository_id in work:
|
|
451
|
+
now = self._now()
|
|
452
|
+
with self._store.transaction() as db:
|
|
453
|
+
db.execute(
|
|
454
|
+
"INSERT INTO repository_effective_policies (account_id, repository_id, state, "
|
|
455
|
+
"invalidated_at) VALUES (?, ?, 'syncing', ?) ON CONFLICT (account_id, "
|
|
456
|
+
"repository_id) DO UPDATE SET state = 'syncing'",
|
|
457
|
+
(account_id, repository_id, ts(now)),
|
|
458
|
+
)
|
|
459
|
+
try:
|
|
460
|
+
with self._store.transaction() as db:
|
|
461
|
+
resolved = self.resolve_in(db, account_id, repository_id)
|
|
462
|
+
self._store_resolution(db, resolved)
|
|
463
|
+
counts["resolved"] += 1
|
|
464
|
+
except Exception as exc: # noqa: BLE001 - one repository must not stop propagation
|
|
465
|
+
log.error(
|
|
466
|
+
"policy_propagation_failed",
|
|
467
|
+
repository_id=repository_id,
|
|
468
|
+
error_type=type(exc).__name__,
|
|
469
|
+
)
|
|
470
|
+
with self._store.transaction() as db:
|
|
471
|
+
db.execute(
|
|
472
|
+
"UPDATE repository_effective_policies SET state = 'error', "
|
|
473
|
+
"attempts = attempts + 1, error = ? WHERE account_id = ? "
|
|
474
|
+
"AND repository_id = ?",
|
|
475
|
+
(f"resolution failed ({type(exc).__name__})", account_id, repository_id),
|
|
476
|
+
)
|
|
477
|
+
counts["failed"] += 1
|
|
478
|
+
failed_accounts.setdefault(account_id, []).append(repository_id)
|
|
479
|
+
for account_id, repositories in failed_accounts.items():
|
|
480
|
+
self._propagation_failed(account_id, repositories)
|
|
481
|
+
return counts
|
|
482
|
+
|
|
483
|
+
def _propagation_failed(self, account_id: int, repositories: list[int]) -> None:
|
|
484
|
+
now = self._now()
|
|
485
|
+
with self._store.transaction() as db:
|
|
486
|
+
stored = self._store.insert_audit_event(
|
|
487
|
+
db,
|
|
488
|
+
self._audit.build(
|
|
489
|
+
AuditEventType.POLICY_PROPAGATION_FAILED,
|
|
490
|
+
actor=SYSTEM_ACTOR,
|
|
491
|
+
account_id=account_id,
|
|
492
|
+
repositories=len(repositories),
|
|
493
|
+
repository_ids=",".join(str(i) for i in repositories[:50]),
|
|
494
|
+
),
|
|
495
|
+
)
|
|
496
|
+
emit(
|
|
497
|
+
db,
|
|
498
|
+
NotificationEvent(
|
|
499
|
+
type=NotificationType.POLICY_PROPAGATION_FAILED,
|
|
500
|
+
account_id=account_id,
|
|
501
|
+
severity=Severity.HIGH,
|
|
502
|
+
resource_type="organization",
|
|
503
|
+
resource_id=str(account_id),
|
|
504
|
+
dedup_key=domain_key(NotificationType.POLICY_PROPAGATION_FAILED, account_id),
|
|
505
|
+
title="Policy propagation failed for some repositories",
|
|
506
|
+
body=(
|
|
507
|
+
f"CommitGuard could not update the effective policy of {len(repositories)} "
|
|
508
|
+
"repository(ies). The organization policy remains active; scans of these "
|
|
509
|
+
"repositories resolve the policy directly and fail closed if that fails. "
|
|
510
|
+
"See the organization dashboard for details."
|
|
511
|
+
),
|
|
512
|
+
metadata={"repositories": len(repositories)},
|
|
513
|
+
),
|
|
514
|
+
now,
|
|
515
|
+
)
|
|
516
|
+
self._audit.log_stored(stored)
|
|
517
|
+
|
|
518
|
+
def propagation_status(self, principal: Principal, account_id: int) -> PropagationStatus:
|
|
519
|
+
require(principal, Permission.POLICIES_READ, account_id)
|
|
520
|
+
repositories = account_repositories(self._store, account_id)
|
|
521
|
+
states = {
|
|
522
|
+
int(r["repository_id"]): (str(r["state"]), r["error"], dt(r["valid_until"]))
|
|
523
|
+
for r in self._store.query(
|
|
524
|
+
"SELECT repository_id, state, error, valid_until FROM "
|
|
525
|
+
"repository_effective_policies WHERE account_id = ?",
|
|
526
|
+
(account_id,),
|
|
527
|
+
)
|
|
528
|
+
}
|
|
529
|
+
now = self._now()
|
|
530
|
+
counts = dict.fromkeys(PROPAGATION_STATES, 0)
|
|
531
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
532
|
+
failing: list[dict[str, object]] = []
|
|
533
|
+
for repository_id, repository in repositories.items():
|
|
534
|
+
state, error, valid_until = states.get(repository_id, ("pending", None, None))
|
|
535
|
+
if state == "up_to_date" and valid_until is not None and valid_until <= now:
|
|
536
|
+
state = "stale" # an included exception expired; not yet re-resolved
|
|
537
|
+
counts[state] += 1
|
|
538
|
+
if state == "error" and repository_id in visible and len(failing) < 50:
|
|
539
|
+
failing.append(
|
|
540
|
+
{
|
|
541
|
+
"repository_id": repository_id,
|
|
542
|
+
"full_name": repository.full_name,
|
|
543
|
+
"error": error,
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
return PropagationStatus(
|
|
547
|
+
organization_id=account_id,
|
|
548
|
+
repositories=len(repositories),
|
|
549
|
+
up_to_date=counts["up_to_date"],
|
|
550
|
+
stale=counts["stale"],
|
|
551
|
+
syncing=counts["syncing"],
|
|
552
|
+
error=counts["error"],
|
|
553
|
+
pending=counts["pending"],
|
|
554
|
+
complete=counts["up_to_date"] == len(repositories),
|
|
555
|
+
failing=tuple(failing),
|
|
556
|
+
checked_at=now,
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
def repository_state(self, account_id: int, repository_id: int) -> str:
|
|
560
|
+
rows = self._store.query(
|
|
561
|
+
"SELECT state, valid_until FROM repository_effective_policies WHERE account_id = ? "
|
|
562
|
+
"AND repository_id = ?",
|
|
563
|
+
(account_id, repository_id),
|
|
564
|
+
)
|
|
565
|
+
if not rows:
|
|
566
|
+
return "pending"
|
|
567
|
+
valid_until = dt(rows[0]["valid_until"])
|
|
568
|
+
expired = valid_until is not None and valid_until <= self._now()
|
|
569
|
+
if rows[0]["state"] == "up_to_date" and expired:
|
|
570
|
+
return "stale"
|
|
571
|
+
return str(rows[0]["state"])
|
|
572
|
+
|
|
573
|
+
# -- views ------------------------------------------------------------ #
|
|
574
|
+
def effective_view(
|
|
575
|
+
self, principal: Principal, account_id: int, repository_id: int
|
|
576
|
+
) -> EffectivePolicyView:
|
|
577
|
+
require(principal, Permission.POLICIES_READ, account_id)
|
|
578
|
+
repository = account_repository(self._store, account_id, repository_id)
|
|
579
|
+
if repository is None or repository_id not in visible_repository_ids(
|
|
580
|
+
self._store, principal, account_id
|
|
581
|
+
):
|
|
582
|
+
raise NotFoundError()
|
|
583
|
+
resolved = self.for_repository(account_id, repository_id)
|
|
584
|
+
state = self.repository_state(account_id, repository_id)
|
|
585
|
+
effective = resolve_policy(resolved.inputs, None)
|
|
586
|
+
last = self._store.query(
|
|
587
|
+
"SELECT j.job_id, j.completed_at, j.governance, j.governance_fingerprint FROM "
|
|
588
|
+
"scan_jobs j JOIN installations i ON i.installation_id = j.installation_id WHERE "
|
|
589
|
+
"i.account_id = ? AND j.repository_id = ? AND j.state IN ('passed', 'failed') "
|
|
590
|
+
"ORDER BY j.completed_at DESC LIMIT 1",
|
|
591
|
+
(account_id, repository_id),
|
|
592
|
+
)
|
|
593
|
+
last_effective = None
|
|
594
|
+
used_current = None
|
|
595
|
+
if last:
|
|
596
|
+
record = last[0]["governance"]
|
|
597
|
+
if record:
|
|
598
|
+
try:
|
|
599
|
+
parsed = json.loads(str(record))
|
|
600
|
+
last_effective = EffectivePolicy.model_validate(parsed.get("effective"))
|
|
601
|
+
except (ValueError, TypeError):
|
|
602
|
+
last_effective = None
|
|
603
|
+
fingerprint = last[0]["governance_fingerprint"]
|
|
604
|
+
used_current = fingerprint == resolved.fingerprint if fingerprint else False
|
|
605
|
+
return EffectivePolicyView(
|
|
606
|
+
organization_id=account_id,
|
|
607
|
+
repository_id=repository_id,
|
|
608
|
+
full_name=repository.full_name,
|
|
609
|
+
mode=resolved.inputs.mode,
|
|
610
|
+
effective=effective,
|
|
611
|
+
versions=resolved.versions,
|
|
612
|
+
propagation=state,
|
|
613
|
+
resolved_at=resolved.resolved_at,
|
|
614
|
+
last_scan_id=last[0]["job_id"] if last else None,
|
|
615
|
+
last_scan_completed_at=dt(last[0]["completed_at"]) if last else None,
|
|
616
|
+
last_scan_effective=last_effective,
|
|
617
|
+
last_scan_used_current_policy=used_current,
|
|
618
|
+
)
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def scan_governance_record(resolved: ResolvedGovernance, effective: EffectivePolicy | None) -> str:
|
|
622
|
+
"""The governance record stored with a completed scan (immutable history)."""
|
|
623
|
+
return json.dumps(
|
|
624
|
+
{
|
|
625
|
+
"versions": resolved.versions.model_dump(mode="json"),
|
|
626
|
+
"inputs_fingerprint": resolved.fingerprint,
|
|
627
|
+
"description": resolved.inputs.describe(),
|
|
628
|
+
"mode": resolved.inputs.mode.value,
|
|
629
|
+
"effective": effective.model_dump(mode="json") if effective else None,
|
|
630
|
+
},
|
|
631
|
+
sort_keys=True,
|
|
632
|
+
)
|