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,618 @@
|
|
|
1
|
+
"""Policy simulation: what a draft policy would have decided, on real past scans.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
draft document
|
|
6
|
+
│
|
|
7
|
+
▼
|
|
8
|
+
for every repository in the target's scope:
|
|
9
|
+
current governance inputs (resolver) ──► resolve_policy ──► policy set A
|
|
10
|
+
the same inputs with the draft layer ──► resolve_policy ──► policy set B
|
|
11
|
+
│
|
|
12
|
+
▼ the findings stored with its recent scans, re-evaluated by the
|
|
13
|
+
real PolicyEvaluator under A and under B
|
|
14
|
+
▼
|
|
15
|
+
projected: new blocks, new warnings, findings no longer blocked, unchanged
|
|
16
|
+
|
|
17
|
+
A simulation is **read-only analysis**. It never writes a policy version, never
|
|
18
|
+
touches a GitHub check, never changes violations or enforcement, and never
|
|
19
|
+
runs a detector: it reuses the recorded findings and the same policy engine
|
|
20
|
+
that produced the original results.
|
|
21
|
+
|
|
22
|
+
It is an **estimate**, and the result says so:
|
|
23
|
+
|
|
24
|
+
* it uses the scans that exist in the selected period, not future commits;
|
|
25
|
+
* scans recorded before Phase 8 did not store the repository's own
|
|
26
|
+
``.commitguard.yaml`` overrides, so built-in defaults are assumed for them
|
|
27
|
+
and the result reports how many;
|
|
28
|
+
* repositories with no scan in the period are reported as "no data".
|
|
29
|
+
|
|
30
|
+
Large organizations run it in the background: a simulation is queued, claimed
|
|
31
|
+
by the maintenance loop with a lease, and bounded by
|
|
32
|
+
:data:`MAX_SCANS` scans and :data:`MAX_FINDINGS` findings.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import json
|
|
36
|
+
import sqlite3
|
|
37
|
+
from collections.abc import Callable, Sequence
|
|
38
|
+
from datetime import UTC, datetime, timedelta
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from pydantic import BaseModel, ConfigDict
|
|
42
|
+
|
|
43
|
+
from commitguard.audit.models import Actor, AuditEventType
|
|
44
|
+
from commitguard.config.schema import CommitGuardConfig, PolicyOverride
|
|
45
|
+
from commitguard.controlplane.access import Permission, Principal
|
|
46
|
+
from commitguard.controlplane.errors import ConflictError, InputValidationError, NotFoundError
|
|
47
|
+
from commitguard.controlplane.policies import PolicyTarget, PolicyTargetType, parse_document
|
|
48
|
+
from commitguard.controlplane.views import PolicyTargetView
|
|
49
|
+
from commitguard.core.decision import Action
|
|
50
|
+
from commitguard.core.result import (
|
|
51
|
+
Confidence,
|
|
52
|
+
DetectionResult,
|
|
53
|
+
Evidence,
|
|
54
|
+
EvidenceSource,
|
|
55
|
+
Finding,
|
|
56
|
+
Severity,
|
|
57
|
+
)
|
|
58
|
+
from commitguard.github.storage import SqliteStateStore
|
|
59
|
+
from commitguard.governance.cache import group_member_ids
|
|
60
|
+
from commitguard.governance.common import (
|
|
61
|
+
account_repositories,
|
|
62
|
+
dt,
|
|
63
|
+
is_hex_id,
|
|
64
|
+
new_id,
|
|
65
|
+
req_dt,
|
|
66
|
+
require,
|
|
67
|
+
ts,
|
|
68
|
+
visible_repository_ids,
|
|
69
|
+
)
|
|
70
|
+
from commitguard.governance.resolver import GovernanceResolver
|
|
71
|
+
from commitguard.observability.logging import get_logger
|
|
72
|
+
from commitguard.policies.evaluator import PolicyEvaluator
|
|
73
|
+
from commitguard.policies.governance import (
|
|
74
|
+
Enforcement,
|
|
75
|
+
GovernanceInputs,
|
|
76
|
+
PolicyLayer,
|
|
77
|
+
PolicyLevel,
|
|
78
|
+
RuleRequirement,
|
|
79
|
+
resolve_policy,
|
|
80
|
+
)
|
|
81
|
+
from commitguard.services.audit import AuditService
|
|
82
|
+
|
|
83
|
+
log = get_logger(__name__)
|
|
84
|
+
|
|
85
|
+
MAX_SCANS = 5_000
|
|
86
|
+
MAX_FINDINGS = 50_000
|
|
87
|
+
MAX_PERIOD_DAYS = 90
|
|
88
|
+
DEFAULT_PERIOD_DAYS = 30
|
|
89
|
+
MAX_OPEN_SIMULATIONS = 3
|
|
90
|
+
SIMULATION_LEASE = timedelta(minutes=10)
|
|
91
|
+
DISCLAIMER = (
|
|
92
|
+
"SIMULATION - an estimate from recorded scans in the selected period, not the current "
|
|
93
|
+
"security state and not a prediction of future commits."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class RepositoryImpact(BaseModel):
|
|
98
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
99
|
+
|
|
100
|
+
repository_id: int
|
|
101
|
+
full_name: str | None # None: not visible to the caller
|
|
102
|
+
scans: int
|
|
103
|
+
new_blocks: int
|
|
104
|
+
new_warnings: int
|
|
105
|
+
no_longer_blocked: int
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class SimulationResult(BaseModel):
|
|
109
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
110
|
+
|
|
111
|
+
repositories_analyzed: int
|
|
112
|
+
repositories_without_data: int
|
|
113
|
+
scans_analyzed: int
|
|
114
|
+
findings_analyzed: int
|
|
115
|
+
new_blocks: int
|
|
116
|
+
new_warnings: int
|
|
117
|
+
no_longer_blocked: int
|
|
118
|
+
unchanged: int
|
|
119
|
+
scans_newly_blocked: int
|
|
120
|
+
scans_no_longer_blocked: int
|
|
121
|
+
scans_assumed_defaults: int # no recorded repository configuration (pre-Phase 8 scans)
|
|
122
|
+
most_affected: tuple[RepositoryImpact, ...]
|
|
123
|
+
truncated: bool
|
|
124
|
+
disclaimer: str = DISCLAIMER
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
class SimulationView(BaseModel):
|
|
128
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
129
|
+
|
|
130
|
+
id: str
|
|
131
|
+
organization_id: int
|
|
132
|
+
target: PolicyTargetView
|
|
133
|
+
draft_id: str | None
|
|
134
|
+
current_version: int
|
|
135
|
+
state: str
|
|
136
|
+
parameters: dict[str, object]
|
|
137
|
+
requested_by: str | None
|
|
138
|
+
requested_at: datetime
|
|
139
|
+
started_at: datetime | None
|
|
140
|
+
completed_at: datetime | None
|
|
141
|
+
result: SimulationResult | None
|
|
142
|
+
error: str | None
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _config_from_overrides(document: str | None) -> tuple[list[CommitGuardConfig], bool]:
|
|
146
|
+
"""The repository configuration recorded with a scan, if any."""
|
|
147
|
+
if not document:
|
|
148
|
+
return [], False
|
|
149
|
+
try:
|
|
150
|
+
raw = json.loads(document)
|
|
151
|
+
except ValueError:
|
|
152
|
+
return [], False
|
|
153
|
+
if not isinstance(raw, dict):
|
|
154
|
+
return [], False
|
|
155
|
+
policies: dict[str, PolicyOverride] = {}
|
|
156
|
+
for rule, fields in raw.items():
|
|
157
|
+
if not isinstance(fields, dict):
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
policies[rule] = PolicyOverride.model_validate(fields)
|
|
161
|
+
except ValueError:
|
|
162
|
+
continue
|
|
163
|
+
return [CommitGuardConfig(version=1, policies=policies)], True
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _finding(row: sqlite3.Row) -> Finding | None:
|
|
167
|
+
try:
|
|
168
|
+
evidence_items = json.loads(str(row["evidence"]))
|
|
169
|
+
except ValueError:
|
|
170
|
+
evidence_items = []
|
|
171
|
+
evidence = []
|
|
172
|
+
for item in evidence_items if isinstance(evidence_items, list) else []:
|
|
173
|
+
if not isinstance(item, dict):
|
|
174
|
+
continue
|
|
175
|
+
try:
|
|
176
|
+
evidence.append(
|
|
177
|
+
Evidence(
|
|
178
|
+
source=EvidenceSource(str(item.get("source", ""))),
|
|
179
|
+
value=str(item.get("value", ""))[:512] or "(recorded)",
|
|
180
|
+
line_number=item.get("line_number"),
|
|
181
|
+
)
|
|
182
|
+
)
|
|
183
|
+
except ValueError:
|
|
184
|
+
continue
|
|
185
|
+
if not evidence:
|
|
186
|
+
evidence = [Evidence(source=EvidenceSource.MESSAGE, value="(recorded finding)")]
|
|
187
|
+
try:
|
|
188
|
+
return Finding(
|
|
189
|
+
detector=str(row["detector"]),
|
|
190
|
+
rule_id=str(row["rule_id"]),
|
|
191
|
+
severity=Severity(row["severity"]),
|
|
192
|
+
confidence=Confidence(row["confidence"]),
|
|
193
|
+
title=str(row["title"]) or "finding",
|
|
194
|
+
message=str(row["message"]) or "recorded finding",
|
|
195
|
+
evidence=tuple(evidence),
|
|
196
|
+
commit_sha=row["commit_sha"],
|
|
197
|
+
remediation=str(row["remediation"]) or "See the violation page.",
|
|
198
|
+
)
|
|
199
|
+
except ValueError: # pragma: no cover - stored rows are validated on write
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
class PolicySimulationService:
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
store: SqliteStateStore,
|
|
207
|
+
audit: AuditService,
|
|
208
|
+
resolver: GovernanceResolver,
|
|
209
|
+
*,
|
|
210
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
211
|
+
) -> None:
|
|
212
|
+
self._store = store
|
|
213
|
+
self._audit = audit
|
|
214
|
+
self._resolver = resolver
|
|
215
|
+
self._now = now
|
|
216
|
+
|
|
217
|
+
# -- requests --------------------------------------------------------- #
|
|
218
|
+
def create(
|
|
219
|
+
self,
|
|
220
|
+
principal: Principal,
|
|
221
|
+
account_id: int,
|
|
222
|
+
*,
|
|
223
|
+
target: PolicyTarget,
|
|
224
|
+
document: str,
|
|
225
|
+
draft_id: str | None,
|
|
226
|
+
period_days: object = None,
|
|
227
|
+
repository_ids: Sequence[int] | None = None,
|
|
228
|
+
) -> SimulationView:
|
|
229
|
+
require(principal, Permission.POLICIES_WRITE, account_id)
|
|
230
|
+
days = DEFAULT_PERIOD_DAYS if period_days is None else period_days
|
|
231
|
+
if not isinstance(days, int) or isinstance(days, bool) or not 1 <= days <= MAX_PERIOD_DAYS:
|
|
232
|
+
raise InputValidationError(
|
|
233
|
+
f"period_days must be between 1 and {MAX_PERIOD_DAYS}", field="period_days"
|
|
234
|
+
)
|
|
235
|
+
parse_document(document) # reject an invalid draft document early
|
|
236
|
+
now = self._now()
|
|
237
|
+
simulation_id = new_id()
|
|
238
|
+
current = self._resolver_policies_current(account_id, target)
|
|
239
|
+
parameters = {
|
|
240
|
+
"period_days": days,
|
|
241
|
+
"repository_ids": sorted(set(repository_ids)) if repository_ids else None,
|
|
242
|
+
}
|
|
243
|
+
with self._store.transaction() as db:
|
|
244
|
+
open_count = db.execute(
|
|
245
|
+
"SELECT COUNT(*) AS n FROM policy_simulations WHERE account_id = ? "
|
|
246
|
+
"AND state IN ('queued', 'running')",
|
|
247
|
+
(account_id,),
|
|
248
|
+
).fetchone()["n"]
|
|
249
|
+
if int(open_count) >= MAX_OPEN_SIMULATIONS:
|
|
250
|
+
raise ConflictError(
|
|
251
|
+
"There are already simulations running for this organization. "
|
|
252
|
+
"Wait for them to finish."
|
|
253
|
+
)
|
|
254
|
+
db.execute(
|
|
255
|
+
"INSERT INTO policy_simulations (simulation_id, account_id, draft_id, "
|
|
256
|
+
"target_type, target_id, current_version, document, parameters, state, "
|
|
257
|
+
"requested_by_id, requested_by_login, requested_at) "
|
|
258
|
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?, ?)",
|
|
259
|
+
(
|
|
260
|
+
simulation_id,
|
|
261
|
+
account_id,
|
|
262
|
+
draft_id,
|
|
263
|
+
target.type.value,
|
|
264
|
+
target.id,
|
|
265
|
+
current,
|
|
266
|
+
document,
|
|
267
|
+
json.dumps(parameters),
|
|
268
|
+
principal.user_id,
|
|
269
|
+
principal.login,
|
|
270
|
+
ts(now),
|
|
271
|
+
),
|
|
272
|
+
)
|
|
273
|
+
return self.get(principal, simulation_id)
|
|
274
|
+
|
|
275
|
+
def _resolver_policies_current(self, account_id: int, target: PolicyTarget) -> int:
|
|
276
|
+
rows = self._store.query(
|
|
277
|
+
"SELECT MAX(version) AS latest FROM organization_policy_versions WHERE account_id = ?"
|
|
278
|
+
if target.type is PolicyTargetType.ORGANIZATION
|
|
279
|
+
else "SELECT MAX(version) AS latest FROM scoped_policy_versions WHERE account_id = ? "
|
|
280
|
+
"AND target_type = ? AND target_id = ?",
|
|
281
|
+
(account_id,)
|
|
282
|
+
if target.type is PolicyTargetType.ORGANIZATION
|
|
283
|
+
else (account_id, target.type.value, target.id),
|
|
284
|
+
)
|
|
285
|
+
return int(rows[0]["latest"] or 0) if rows else 0
|
|
286
|
+
|
|
287
|
+
# -- reads ------------------------------------------------------------ #
|
|
288
|
+
def _row(self, simulation_id: str) -> sqlite3.Row:
|
|
289
|
+
if not is_hex_id(simulation_id):
|
|
290
|
+
raise NotFoundError()
|
|
291
|
+
rows = self._store.query(
|
|
292
|
+
"SELECT * FROM policy_simulations WHERE simulation_id = ?", (simulation_id,)
|
|
293
|
+
)
|
|
294
|
+
if not rows:
|
|
295
|
+
raise NotFoundError()
|
|
296
|
+
return rows[0]
|
|
297
|
+
|
|
298
|
+
def get(self, principal: Principal, simulation_id: str) -> SimulationView:
|
|
299
|
+
row = self._row(simulation_id)
|
|
300
|
+
account_id = int(row["account_id"])
|
|
301
|
+
require(principal, Permission.POLICIES_READ, account_id)
|
|
302
|
+
return self._view(row, principal)
|
|
303
|
+
|
|
304
|
+
def list_simulations(
|
|
305
|
+
self, principal: Principal, account_id: int, *, draft_id: str | None = None
|
|
306
|
+
) -> list[SimulationView]:
|
|
307
|
+
require(principal, Permission.POLICIES_READ, account_id)
|
|
308
|
+
sql = "SELECT * FROM policy_simulations WHERE account_id = ?"
|
|
309
|
+
params: list[object] = [account_id]
|
|
310
|
+
if draft_id is not None:
|
|
311
|
+
sql += " AND draft_id = ?"
|
|
312
|
+
params.append(draft_id)
|
|
313
|
+
rows = self._store.query(sql + " ORDER BY requested_at DESC LIMIT 50", params)
|
|
314
|
+
return [self._view(row, principal) for row in rows]
|
|
315
|
+
|
|
316
|
+
def _view(self, row: sqlite3.Row, principal: Principal) -> SimulationView:
|
|
317
|
+
account_id = int(row["account_id"])
|
|
318
|
+
target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
|
|
319
|
+
result = None
|
|
320
|
+
if row["result"]:
|
|
321
|
+
try:
|
|
322
|
+
parsed = SimulationResult.model_validate_json(str(row["result"]))
|
|
323
|
+
except ValueError:
|
|
324
|
+
parsed = None
|
|
325
|
+
if parsed is not None:
|
|
326
|
+
visible = visible_repository_ids(self._store, principal, account_id)
|
|
327
|
+
result = parsed.model_copy(
|
|
328
|
+
update={
|
|
329
|
+
"most_affected": tuple(
|
|
330
|
+
impact
|
|
331
|
+
if impact.repository_id in visible
|
|
332
|
+
else impact.model_copy(update={"full_name": None})
|
|
333
|
+
for impact in parsed.most_affected
|
|
334
|
+
)
|
|
335
|
+
}
|
|
336
|
+
)
|
|
337
|
+
with self._store.transaction() as db:
|
|
338
|
+
from commitguard.controlplane.policies import target_label
|
|
339
|
+
|
|
340
|
+
label = target_label(db, account_id, target) or "(removed)"
|
|
341
|
+
return SimulationView(
|
|
342
|
+
id=str(row["simulation_id"]),
|
|
343
|
+
organization_id=account_id,
|
|
344
|
+
target=PolicyTargetView(type=target.type.value, id=target.id, label=label),
|
|
345
|
+
draft_id=row["draft_id"],
|
|
346
|
+
current_version=int(row["current_version"]),
|
|
347
|
+
state=str(row["state"]),
|
|
348
|
+
parameters=json.loads(str(row["parameters"])),
|
|
349
|
+
requested_by=row["requested_by_login"],
|
|
350
|
+
requested_at=req_dt(row["requested_at"]),
|
|
351
|
+
started_at=dt(row["started_at"]),
|
|
352
|
+
completed_at=dt(row["completed_at"]),
|
|
353
|
+
result=result,
|
|
354
|
+
error=row["error"],
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# -- execution --------------------------------------------------------- #
|
|
358
|
+
def run_pending(self, limit: int = 1) -> int:
|
|
359
|
+
"""Claim and run queued simulations (maintenance loop). Read-only analysis."""
|
|
360
|
+
now = self._now()
|
|
361
|
+
rows = self._store.query(
|
|
362
|
+
"SELECT simulation_id FROM policy_simulations WHERE state = 'queued' "
|
|
363
|
+
"OR (state = 'running' AND lease_expires_at < ?) ORDER BY requested_at LIMIT ?",
|
|
364
|
+
(ts(now), int(limit)),
|
|
365
|
+
)
|
|
366
|
+
done = 0
|
|
367
|
+
for row in rows:
|
|
368
|
+
simulation_id = str(row["simulation_id"])
|
|
369
|
+
with self._store.transaction() as db:
|
|
370
|
+
claimed = db.execute(
|
|
371
|
+
"UPDATE policy_simulations SET state = 'running', started_at = ?, "
|
|
372
|
+
"lease_expires_at = ?, attempts = attempts + 1 WHERE simulation_id = ? "
|
|
373
|
+
"AND (state = 'queued' OR (state = 'running' AND lease_expires_at < ?))",
|
|
374
|
+
(
|
|
375
|
+
ts(now),
|
|
376
|
+
ts(now + SIMULATION_LEASE),
|
|
377
|
+
simulation_id,
|
|
378
|
+
ts(now),
|
|
379
|
+
),
|
|
380
|
+
).rowcount
|
|
381
|
+
if claimed != 1:
|
|
382
|
+
continue
|
|
383
|
+
claimed_row = self._row(simulation_id)
|
|
384
|
+
requester = Actor.user(
|
|
385
|
+
int(claimed_row["requested_by_id"] or 0),
|
|
386
|
+
str(claimed_row["requested_by_login"] or "unknown"),
|
|
387
|
+
)
|
|
388
|
+
try:
|
|
389
|
+
result = self._simulate(claimed_row)
|
|
390
|
+
with self._store.transaction() as db:
|
|
391
|
+
db.execute(
|
|
392
|
+
"UPDATE policy_simulations SET state = 'completed', completed_at = ?, "
|
|
393
|
+
"result = ?, lease_expires_at = NULL WHERE simulation_id = ?",
|
|
394
|
+
(ts(self._now()), result.model_dump_json(), simulation_id),
|
|
395
|
+
)
|
|
396
|
+
stored = self._store.insert_audit_event(
|
|
397
|
+
db,
|
|
398
|
+
self._audit.build(
|
|
399
|
+
AuditEventType.POLICY_SIMULATED,
|
|
400
|
+
actor=requester,
|
|
401
|
+
account_id=int(claimed_row["account_id"]),
|
|
402
|
+
simulation=simulation_id,
|
|
403
|
+
scans=result.scans_analyzed,
|
|
404
|
+
new_blocks=result.new_blocks,
|
|
405
|
+
new_warnings=result.new_warnings,
|
|
406
|
+
no_longer_blocked=result.no_longer_blocked,
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
self._audit.log_stored(stored)
|
|
410
|
+
done += 1
|
|
411
|
+
except Exception as exc: # noqa: BLE001 - a failed simulation changes nothing
|
|
412
|
+
log.error(
|
|
413
|
+
"policy_simulation_failed",
|
|
414
|
+
simulation=simulation_id,
|
|
415
|
+
error_type=type(exc).__name__,
|
|
416
|
+
)
|
|
417
|
+
with self._store.transaction() as db:
|
|
418
|
+
db.execute(
|
|
419
|
+
"UPDATE policy_simulations SET state = 'failed', completed_at = ?, "
|
|
420
|
+
"error = ?, lease_expires_at = NULL WHERE simulation_id = ?",
|
|
421
|
+
(
|
|
422
|
+
ts(self._now()),
|
|
423
|
+
f"simulation failed ({type(exc).__name__})",
|
|
424
|
+
simulation_id,
|
|
425
|
+
),
|
|
426
|
+
)
|
|
427
|
+
return done
|
|
428
|
+
|
|
429
|
+
def _draft_layer(self, target: PolicyTarget, document: str, version: int) -> PolicyLayer | None:
|
|
430
|
+
floors, defaults = parse_document(document)
|
|
431
|
+
rules = {
|
|
432
|
+
rule: RuleRequirement(action=action, enforcement=Enforcement.DEFAULT)
|
|
433
|
+
for rule, action in defaults.items()
|
|
434
|
+
}
|
|
435
|
+
rules.update({rule: RuleRequirement(action=action) for rule, action in floors.items()})
|
|
436
|
+
if not rules:
|
|
437
|
+
return None
|
|
438
|
+
level = {
|
|
439
|
+
PolicyTargetType.ORGANIZATION: PolicyLevel.ORGANIZATION,
|
|
440
|
+
PolicyTargetType.GROUP: PolicyLevel.GROUP,
|
|
441
|
+
PolicyTargetType.REPOSITORY: PolicyLevel.REPOSITORY_POLICY,
|
|
442
|
+
}[target.type]
|
|
443
|
+
return PolicyLayer(
|
|
444
|
+
level=level,
|
|
445
|
+
source_id=target.id,
|
|
446
|
+
label=f"draft policy (would be v{version + 1})",
|
|
447
|
+
rules=rules,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
@staticmethod
|
|
451
|
+
def _with_draft(
|
|
452
|
+
inputs: GovernanceInputs, target: PolicyTarget, draft: PolicyLayer | None
|
|
453
|
+
) -> GovernanceInputs:
|
|
454
|
+
"""The same inputs with the target's layer replaced by the draft."""
|
|
455
|
+
kept = tuple(
|
|
456
|
+
layer
|
|
457
|
+
for layer in inputs.layers
|
|
458
|
+
if not (
|
|
459
|
+
layer.source_id == target.id
|
|
460
|
+
and layer.level
|
|
461
|
+
in (
|
|
462
|
+
PolicyLevel.ORGANIZATION
|
|
463
|
+
if target.type is PolicyTargetType.ORGANIZATION
|
|
464
|
+
else PolicyLevel.GROUP
|
|
465
|
+
if target.type is PolicyTargetType.GROUP
|
|
466
|
+
else PolicyLevel.REPOSITORY_POLICY,
|
|
467
|
+
)
|
|
468
|
+
)
|
|
469
|
+
# The security baseline is a separate organization-level layer and stays.
|
|
470
|
+
or layer.source_id == "baseline"
|
|
471
|
+
)
|
|
472
|
+
return inputs.model_copy(update={"layers": (*kept, draft) if draft is not None else kept})
|
|
473
|
+
|
|
474
|
+
def _scope(self, db: sqlite3.Connection, account_id: int, target: PolicyTarget) -> list[int]:
|
|
475
|
+
repositories = sorted(account_repositories(db, account_id))
|
|
476
|
+
if target.type is PolicyTargetType.GROUP:
|
|
477
|
+
members = set(group_member_ids(db, account_id, target.id))
|
|
478
|
+
return [r for r in repositories if r in members]
|
|
479
|
+
if target.type is PolicyTargetType.REPOSITORY:
|
|
480
|
+
wanted = int(target.id)
|
|
481
|
+
return [r for r in repositories if r == wanted]
|
|
482
|
+
return repositories
|
|
483
|
+
|
|
484
|
+
def _simulate(self, row: sqlite3.Row) -> SimulationResult:
|
|
485
|
+
account_id = int(row["account_id"])
|
|
486
|
+
target = PolicyTarget(PolicyTargetType(row["target_type"]), str(row["target_id"]))
|
|
487
|
+
parameters = json.loads(str(row["parameters"]))
|
|
488
|
+
period = timedelta(days=int(parameters.get("period_days", DEFAULT_PERIOD_DAYS)))
|
|
489
|
+
wanted = parameters.get("repository_ids")
|
|
490
|
+
since = self._now() - period
|
|
491
|
+
draft_layer = self._draft_layer(target, str(row["document"]), int(row["current_version"]))
|
|
492
|
+
|
|
493
|
+
with self._store.transaction() as db:
|
|
494
|
+
scope = self._scope(db, account_id, target)
|
|
495
|
+
if wanted:
|
|
496
|
+
scope = [r for r in scope if r in set(wanted)]
|
|
497
|
+
names = {
|
|
498
|
+
repository_id: repository.full_name
|
|
499
|
+
for repository_id, repository in account_repositories(db, account_id).items()
|
|
500
|
+
}
|
|
501
|
+
policy_sets = {}
|
|
502
|
+
for repository_id in scope:
|
|
503
|
+
resolved = self._resolver.resolve_in(db, account_id, repository_id)
|
|
504
|
+
policy_sets[repository_id] = (
|
|
505
|
+
resolved.inputs,
|
|
506
|
+
self._with_draft(resolved.inputs, target, draft_layer),
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
totals = dict.fromkeys(
|
|
510
|
+
(
|
|
511
|
+
"new_blocks",
|
|
512
|
+
"new_warnings",
|
|
513
|
+
"no_longer_blocked",
|
|
514
|
+
"unchanged",
|
|
515
|
+
"scans",
|
|
516
|
+
"findings",
|
|
517
|
+
"scans_newly_blocked",
|
|
518
|
+
"scans_no_longer_blocked",
|
|
519
|
+
"assumed",
|
|
520
|
+
),
|
|
521
|
+
0,
|
|
522
|
+
)
|
|
523
|
+
impacts: list[RepositoryImpact] = []
|
|
524
|
+
repositories_without_data = 0
|
|
525
|
+
repositories_analyzed = 0
|
|
526
|
+
truncated = False
|
|
527
|
+
for repository_id in scope:
|
|
528
|
+
if totals["scans"] >= MAX_SCANS or totals["findings"] >= MAX_FINDINGS:
|
|
529
|
+
truncated = True # the remaining repositories were not analyzed
|
|
530
|
+
break
|
|
531
|
+
scans = self._store.query(
|
|
532
|
+
"SELECT j.job_id, j.repository_policies, j.group_key, j.sequence FROM scan_jobs j "
|
|
533
|
+
"WHERE j.repository_id = ? AND j.state IN ('passed', 'failed') "
|
|
534
|
+
"AND j.completed_at >= ? AND NOT EXISTS (SELECT 1 FROM scan_jobs n WHERE "
|
|
535
|
+
"n.repository_id = j.repository_id AND n.group_key = j.group_key "
|
|
536
|
+
"AND n.state IN ('passed', 'failed') AND n.sequence > j.sequence) "
|
|
537
|
+
"ORDER BY j.sequence DESC LIMIT 200",
|
|
538
|
+
(repository_id, ts(since)),
|
|
539
|
+
)
|
|
540
|
+
if not scans:
|
|
541
|
+
repositories_without_data += 1
|
|
542
|
+
continue
|
|
543
|
+
repositories_analyzed += 1
|
|
544
|
+
current_inputs, draft_inputs = policy_sets[repository_id]
|
|
545
|
+
impact = {"new_blocks": 0, "new_warnings": 0, "no_longer_blocked": 0, "scans": 0}
|
|
546
|
+
# Scans of one repository usually share a configuration: resolve each once.
|
|
547
|
+
policy_cache: dict[str | None, tuple[Any, Any, bool]] = {}
|
|
548
|
+
for scan in scans:
|
|
549
|
+
key = scan["repository_policies"]
|
|
550
|
+
if key not in policy_cache:
|
|
551
|
+
configs, recorded = _config_from_overrides(key)
|
|
552
|
+
policy_cache[key] = (
|
|
553
|
+
resolve_policy(current_inputs, configs).policy_set(),
|
|
554
|
+
resolve_policy(draft_inputs, configs).policy_set(),
|
|
555
|
+
recorded,
|
|
556
|
+
)
|
|
557
|
+
current_policy, draft_policy, recorded = policy_cache[key]
|
|
558
|
+
if not recorded:
|
|
559
|
+
totals["assumed"] += 1
|
|
560
|
+
findings = self._store.query(
|
|
561
|
+
"SELECT detector, rule_id, severity, confidence, title, message, "
|
|
562
|
+
"remediation, evidence, commit_sha FROM findings WHERE job_id = ? LIMIT 2000",
|
|
563
|
+
(scan["job_id"],),
|
|
564
|
+
)
|
|
565
|
+
rebuilt = [f for f in (_finding(r) for r in findings) if f is not None]
|
|
566
|
+
if not rebuilt:
|
|
567
|
+
continue
|
|
568
|
+
totals["scans"] += 1
|
|
569
|
+
impact["scans"] += 1
|
|
570
|
+
totals["findings"] += len(rebuilt)
|
|
571
|
+
result = DetectionResult(commit_sha=None, detectors_run=(), findings=tuple(rebuilt))
|
|
572
|
+
before = PolicyEvaluator(current_policy).evaluate(result)
|
|
573
|
+
after = PolicyEvaluator(draft_policy).evaluate(result)
|
|
574
|
+
for old, new in zip(before.explanations, after.explanations, strict=True):
|
|
575
|
+
if old.action is new.action:
|
|
576
|
+
totals["unchanged"] += 1
|
|
577
|
+
elif new.action is Action.BLOCK:
|
|
578
|
+
totals["new_blocks"] += 1
|
|
579
|
+
impact["new_blocks"] += 1
|
|
580
|
+
elif new.action is Action.WARN and old.action is Action.ALLOW:
|
|
581
|
+
totals["new_warnings"] += 1
|
|
582
|
+
impact["new_warnings"] += 1
|
|
583
|
+
else:
|
|
584
|
+
totals["no_longer_blocked"] += 1
|
|
585
|
+
impact["no_longer_blocked"] += 1
|
|
586
|
+
if before.action is not Action.BLOCK and after.action is Action.BLOCK:
|
|
587
|
+
totals["scans_newly_blocked"] += 1
|
|
588
|
+
elif before.action is Action.BLOCK and after.action is not Action.BLOCK:
|
|
589
|
+
totals["scans_no_longer_blocked"] += 1
|
|
590
|
+
if impact["new_blocks"] or impact["new_warnings"] or impact["no_longer_blocked"]:
|
|
591
|
+
impacts.append(
|
|
592
|
+
RepositoryImpact(
|
|
593
|
+
repository_id=repository_id,
|
|
594
|
+
full_name=names.get(repository_id),
|
|
595
|
+
scans=impact["scans"],
|
|
596
|
+
new_blocks=impact["new_blocks"],
|
|
597
|
+
new_warnings=impact["new_warnings"],
|
|
598
|
+
no_longer_blocked=impact["no_longer_blocked"],
|
|
599
|
+
)
|
|
600
|
+
)
|
|
601
|
+
impacts.sort(
|
|
602
|
+
key=lambda i: i.new_blocks + i.new_warnings + i.no_longer_blocked, reverse=True
|
|
603
|
+
)
|
|
604
|
+
return SimulationResult(
|
|
605
|
+
repositories_analyzed=repositories_analyzed,
|
|
606
|
+
repositories_without_data=repositories_without_data,
|
|
607
|
+
scans_analyzed=totals["scans"],
|
|
608
|
+
findings_analyzed=totals["findings"],
|
|
609
|
+
new_blocks=totals["new_blocks"],
|
|
610
|
+
new_warnings=totals["new_warnings"],
|
|
611
|
+
no_longer_blocked=totals["no_longer_blocked"],
|
|
612
|
+
unchanged=totals["unchanged"],
|
|
613
|
+
scans_newly_blocked=totals["scans_newly_blocked"],
|
|
614
|
+
scans_no_longer_blocked=totals["scans_no_longer_blocked"],
|
|
615
|
+
scans_assumed_defaults=totals["assumed"],
|
|
616
|
+
most_affected=tuple(impacts[:10]),
|
|
617
|
+
truncated=truncated,
|
|
618
|
+
)
|