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,909 @@
|
|
|
1
|
+
"""Recording scan results and tracking whether each violation is still present.
|
|
2
|
+
|
|
3
|
+
Finding versus violation
|
|
4
|
+
========================
|
|
5
|
+
|
|
6
|
+
* A **finding** row is one detector finding in one scan: immutable history.
|
|
7
|
+
Every finding of a completed scan is stored, including findings a disabled
|
|
8
|
+
policy allowed.
|
|
9
|
+
* A **violation** is a finding whose policy action was ``block`` or ``warn``,
|
|
10
|
+
tracked across scans by the finding fingerprint (detector, rule, commit SHA
|
|
11
|
+
and evidence) within one repository. The same attribution on the same commit
|
|
12
|
+
is one violation however often it is scanned.
|
|
13
|
+
|
|
14
|
+
Violation status
|
|
15
|
+
================
|
|
16
|
+
|
|
17
|
+
A violation is *exposed* wherever CommitGuard saw it:
|
|
18
|
+
|
|
19
|
+
* **pull request exposure** - active while the newest completed scan of that
|
|
20
|
+
pull request still contains the finding. Pull request scans always cover the
|
|
21
|
+
complete ``base..head`` range, so absence means the commit left the pull
|
|
22
|
+
request (rewritten, removed, or the base now contains a fixed history). A
|
|
23
|
+
closed pull request ends the exposure; a merged one moves it to the base
|
|
24
|
+
branch.
|
|
25
|
+
* **merge group exposure** - active while the merge queue's temporary merge
|
|
26
|
+
group commit that contained the finding exists; it ends when GitHub destroys
|
|
27
|
+
the merge group (merged into the base branch: the exposure moves to that
|
|
28
|
+
branch; invalidated or dequeued: it ends).
|
|
29
|
+
* **branch exposure** - push scans are incremental (only new commits), so
|
|
30
|
+
absence proves nothing. The exposure ends when a later push scan of that
|
|
31
|
+
branch shows the commit is no longer reachable from the branch head (history
|
|
32
|
+
was rewritten), or the branch is deleted. When reachability cannot be
|
|
33
|
+
determined the exposure stays active: CommitGuard does not guess in the
|
|
34
|
+
permissive direction.
|
|
35
|
+
|
|
36
|
+
``open`` at least one active exposure: the violation is currently present
|
|
37
|
+
in a pull request or branch CommitGuard monitors.
|
|
38
|
+
``acknowledged`` open, and a security manager has recorded that they reviewed it.
|
|
39
|
+
Enforcement is unchanged: GitHub checks still fail.
|
|
40
|
+
``resolved`` no active exposure remains. Set only by CommitGuard from scan and
|
|
41
|
+
GitHub events, never by a user. History is kept.
|
|
42
|
+
|
|
43
|
+
A violation that appears again after being resolved is reopened and its
|
|
44
|
+
acknowledgement cleared, so a new occurrence is reviewed again.
|
|
45
|
+
|
|
46
|
+
Stale results: if a newer scan of the same pull request or branch has already
|
|
47
|
+
completed, an older scan's findings are stored as history but do not change
|
|
48
|
+
exposures, so a slow scan cannot reopen or close anything behind a newer one.
|
|
49
|
+
|
|
50
|
+
Notifications: newly opened (or reopened) violations that a scan *blocked* with
|
|
51
|
+
severity ``high`` or ``critical`` produce one notification per rule per pull
|
|
52
|
+
request, branch or merge group - not one per commit - written to the
|
|
53
|
+
notification outbox in the same transaction as the violations. A blocked merge
|
|
54
|
+
group additionally produces a ``merge_queue_failure`` notification.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
import json
|
|
58
|
+
import sqlite3
|
|
59
|
+
import uuid
|
|
60
|
+
from collections.abc import Callable, Iterable
|
|
61
|
+
from dataclasses import dataclass
|
|
62
|
+
from datetime import UTC, datetime
|
|
63
|
+
from typing import Any
|
|
64
|
+
|
|
65
|
+
from commitguard.audit.models import AuditEvent, AuditEventType
|
|
66
|
+
from commitguard.core.decision import Action
|
|
67
|
+
from commitguard.core.result import Severity
|
|
68
|
+
from commitguard.git.repository import Repository
|
|
69
|
+
from commitguard.github.pull_requests import branch_group_key, group_key, merge_group_key
|
|
70
|
+
from commitguard.github.storage import JobState, ScanJob, SqliteStateStore
|
|
71
|
+
from commitguard.notifications.deduplication import domain_key
|
|
72
|
+
from commitguard.notifications.models import NotificationEvent, NotificationType
|
|
73
|
+
from commitguard.notifications.outbox import account_for_installation, emit
|
|
74
|
+
from commitguard.observability.logging import get_logger
|
|
75
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
76
|
+
from commitguard.security.secrets import redact
|
|
77
|
+
from commitguard.services.audit import AuditService
|
|
78
|
+
from commitguard.services.reports import EvaluatedFinding
|
|
79
|
+
from commitguard.services.scan import ScanResult
|
|
80
|
+
|
|
81
|
+
log = get_logger(__name__)
|
|
82
|
+
|
|
83
|
+
MAX_IDENTITY_CHARS = 256
|
|
84
|
+
MAX_TEXT_CHARS = 1000
|
|
85
|
+
MAX_EVIDENCE_VALUE_CHARS = 512
|
|
86
|
+
MAX_IDENTITY_LOOKUPS = 500
|
|
87
|
+
TRACKED_ACTIONS = (Action.BLOCK, Action.WARN)
|
|
88
|
+
NOTIFY_SEVERITIES = {
|
|
89
|
+
Severity.CRITICAL: NotificationType.CRITICAL_VIOLATION,
|
|
90
|
+
Severity.HIGH: NotificationType.HIGH_VIOLATION,
|
|
91
|
+
}
|
|
92
|
+
MAX_NOTIFIED_VIOLATIONS = 20
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clean_text(value: str, limit: int = MAX_TEXT_CHARS) -> str:
|
|
96
|
+
"""Untrusted text as stored for display: secrets redacted, control characters visible."""
|
|
97
|
+
return sanitize_for_terminal(redact(value), max_length=limit)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _ts(value: datetime) -> float:
|
|
101
|
+
return value.timestamp()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True, slots=True)
|
|
105
|
+
class _Group:
|
|
106
|
+
key: str
|
|
107
|
+
kind: str # "pull_request" | "branch" | "merge_group"
|
|
108
|
+
label: str
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def job_group(job: ScanJob) -> _Group:
|
|
112
|
+
if job.event == "merge_group":
|
|
113
|
+
base = (job.context.ref or "").removeprefix("refs/heads/")
|
|
114
|
+
return _Group(job.group_key, "merge_group", clean_text(f"merge queue ({base})", 256))
|
|
115
|
+
if job.pull_request_number is not None:
|
|
116
|
+
return _Group(
|
|
117
|
+
group_key(job.pull_request_number), "pull_request", f"#{job.pull_request_number}"
|
|
118
|
+
)
|
|
119
|
+
ref = job.context.ref or ""
|
|
120
|
+
return _Group(branch_group_key(ref), "branch", clean_text(ref, 256))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _closed_reason(number: int) -> str:
|
|
124
|
+
return f"pull request #{number} was closed without merging"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _evidence_document(item: EvaluatedFinding) -> str:
|
|
128
|
+
return json.dumps(
|
|
129
|
+
[
|
|
130
|
+
{
|
|
131
|
+
"source": evidence.source.value,
|
|
132
|
+
"source_label": evidence.source.label,
|
|
133
|
+
"value": clean_text(evidence.value, MAX_EVIDENCE_VALUE_CHARS),
|
|
134
|
+
"line_number": evidence.line_number,
|
|
135
|
+
"matched": [
|
|
136
|
+
{
|
|
137
|
+
"kind": reason.kind.value,
|
|
138
|
+
"value": clean_text(reason.value, 256),
|
|
139
|
+
"rule": clean_text(reason.rule, 256),
|
|
140
|
+
}
|
|
141
|
+
for reason in evidence.matched
|
|
142
|
+
],
|
|
143
|
+
"notes": [clean_text(note, 256) for note in evidence.notes],
|
|
144
|
+
}
|
|
145
|
+
for evidence in item.finding.evidence
|
|
146
|
+
],
|
|
147
|
+
ensure_ascii=True,
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ScanResultRecorder:
|
|
152
|
+
def __init__(
|
|
153
|
+
self,
|
|
154
|
+
store: SqliteStateStore,
|
|
155
|
+
audit: AuditService,
|
|
156
|
+
*,
|
|
157
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
158
|
+
) -> None:
|
|
159
|
+
self._store = store
|
|
160
|
+
self._audit = audit
|
|
161
|
+
self._now = now
|
|
162
|
+
|
|
163
|
+
# ------------------------------------------------------------------ #
|
|
164
|
+
# Scan completion
|
|
165
|
+
# ------------------------------------------------------------------ #
|
|
166
|
+
def record_completed(
|
|
167
|
+
self,
|
|
168
|
+
job: ScanJob,
|
|
169
|
+
result: ScanResult,
|
|
170
|
+
*,
|
|
171
|
+
state: JobState,
|
|
172
|
+
conclusion: str,
|
|
173
|
+
repository: Repository | None,
|
|
174
|
+
organization_policy_version: int | None,
|
|
175
|
+
governance_record: str | None = None,
|
|
176
|
+
governance_fingerprint: str | None = None,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Store the result, its findings and the violation lifecycle in one transaction."""
|
|
179
|
+
now = self._now()
|
|
180
|
+
# Errors and cancellations never reach this method: they change no violation state.
|
|
181
|
+
findings = [f for commit in result.report.commits for f in commit.findings]
|
|
182
|
+
identities = self._identities(repository, {f.finding.commit_sha for f in findings})
|
|
183
|
+
group = job_group(job)
|
|
184
|
+
current = {f.fingerprint for f in findings if f.action in TRACKED_ACTIONS}
|
|
185
|
+
# Reachability checks run Git, so they happen before the database transaction.
|
|
186
|
+
unreachable = self._unreachable_branch_exposures(job, group, current, repository)
|
|
187
|
+
|
|
188
|
+
stats = result.statistics
|
|
189
|
+
metadata = result.metadata
|
|
190
|
+
notices = [*result.plan.notices]
|
|
191
|
+
# Monitor mode reports block as warn; alerts still go out for what would have blocked.
|
|
192
|
+
monitored = (
|
|
193
|
+
{r.policy_id for r in result.effective.rules if r.monitor_mode}
|
|
194
|
+
if result.effective is not None
|
|
195
|
+
else set()
|
|
196
|
+
)
|
|
197
|
+
events: list[AuditEvent] = []
|
|
198
|
+
with self._store.transaction() as db:
|
|
199
|
+
db.execute(
|
|
200
|
+
"UPDATE scan_jobs SET state = ?, scan_id = ?, result_action = ?, conclusion = ?, "
|
|
201
|
+
"commits_scanned = ?, violations = ?, warnings = ?, lease_expires_at = NULL, "
|
|
202
|
+
"base_sha = ?, completed_at = ?, tool_version = ?, rules_version = ?, "
|
|
203
|
+
"policy_version = ?, policy_source = ?, organization_policy_version = ?, "
|
|
204
|
+
"effective_policies = ?, findings_count = ?, detector_failures = ?, notices = ?, "
|
|
205
|
+
"governance = ?, governance_fingerprint = ?, repository_policies = ?, "
|
|
206
|
+
"updated_at = ? WHERE job_id = ?",
|
|
207
|
+
(
|
|
208
|
+
state.value,
|
|
209
|
+
metadata.scan_id,
|
|
210
|
+
result.action.value,
|
|
211
|
+
conclusion,
|
|
212
|
+
stats.commits_scanned,
|
|
213
|
+
stats.violations,
|
|
214
|
+
stats.warnings,
|
|
215
|
+
metadata.base_sha,
|
|
216
|
+
_ts(now),
|
|
217
|
+
metadata.tool_version,
|
|
218
|
+
metadata.rules_version,
|
|
219
|
+
metadata.policy_version,
|
|
220
|
+
clean_text(metadata.policy_source, 500),
|
|
221
|
+
organization_policy_version,
|
|
222
|
+
json.dumps(
|
|
223
|
+
[
|
|
224
|
+
{"id": p.id, "enabled": p.enabled, "action": p.action.value}
|
|
225
|
+
for p in result.policies
|
|
226
|
+
]
|
|
227
|
+
),
|
|
228
|
+
stats.findings,
|
|
229
|
+
stats.detector_failures,
|
|
230
|
+
json.dumps([clean_text(n, 1000) for n in notices[:20]]),
|
|
231
|
+
governance_record,
|
|
232
|
+
governance_fingerprint,
|
|
233
|
+
json.dumps(result.repository_overrides, sort_keys=True)
|
|
234
|
+
if result.effective is not None
|
|
235
|
+
else None,
|
|
236
|
+
_ts(now),
|
|
237
|
+
job.job_id,
|
|
238
|
+
),
|
|
239
|
+
)
|
|
240
|
+
stale = self._newer_scan_completed(db, job)
|
|
241
|
+
touched: set[str] = set()
|
|
242
|
+
new_blocked: dict[tuple[NotificationType, str], list[str]] = {}
|
|
243
|
+
for item in findings:
|
|
244
|
+
violation_id = None
|
|
245
|
+
if item.action in TRACKED_ACTIONS:
|
|
246
|
+
violation_id, event = self._upsert_violation(
|
|
247
|
+
db, job, item, identities, now, stale=stale
|
|
248
|
+
)
|
|
249
|
+
touched.add(violation_id)
|
|
250
|
+
if event is not None:
|
|
251
|
+
events.append(event)
|
|
252
|
+
notification_type = NOTIFY_SEVERITIES.get(item.finding.severity)
|
|
253
|
+
would_block = item.action is Action.BLOCK or (
|
|
254
|
+
item.action is Action.WARN and item.finding.rule_id in monitored
|
|
255
|
+
)
|
|
256
|
+
if not stale and would_block and notification_type is not None:
|
|
257
|
+
key = (notification_type, item.finding.rule_id)
|
|
258
|
+
new_blocked.setdefault(key, []).append(violation_id)
|
|
259
|
+
if not stale:
|
|
260
|
+
self._activate_exposure(db, job, group, violation_id, now)
|
|
261
|
+
author, committer = identities.get(item.finding.commit_sha or "", (None, None))
|
|
262
|
+
self._insert_finding(db, job, item, violation_id, author, committer, now)
|
|
263
|
+
if not stale:
|
|
264
|
+
closed = self._close_absent_exposures(db, job, group, current, unreachable, now)
|
|
265
|
+
touched.update(closed)
|
|
266
|
+
events.extend(self._refresh_statuses(db, touched, now))
|
|
267
|
+
events = [self._store.insert_audit_event(db, e) for e in events]
|
|
268
|
+
account_id = account_for_installation(db, job.installation_id)
|
|
269
|
+
if account_id is not None:
|
|
270
|
+
aggregate = bool(new_blocked) and _aggregates_violations(db, account_id)
|
|
271
|
+
for (notification_type, rule_id), ids in sorted(new_blocked.items()):
|
|
272
|
+
notification = self._violation_notification(
|
|
273
|
+
job,
|
|
274
|
+
group,
|
|
275
|
+
account_id,
|
|
276
|
+
notification_type,
|
|
277
|
+
rule_id,
|
|
278
|
+
ids,
|
|
279
|
+
monitored=rule_id in monitored,
|
|
280
|
+
aggregated=aggregate,
|
|
281
|
+
)
|
|
282
|
+
emit(db, notification, now)
|
|
283
|
+
if aggregate:
|
|
284
|
+
emit(db, _violation_digest(db, notification, now), now)
|
|
285
|
+
if job.event == "merge_group" and state is JobState.FAILED and not stale:
|
|
286
|
+
emit(
|
|
287
|
+
db,
|
|
288
|
+
merge_queue_failure(
|
|
289
|
+
job,
|
|
290
|
+
account_id,
|
|
291
|
+
f"CommitGuard blocked the merge group: {stats.violations} "
|
|
292
|
+
"violation(s). The merge queue cannot merge it.",
|
|
293
|
+
),
|
|
294
|
+
now,
|
|
295
|
+
)
|
|
296
|
+
for event in events:
|
|
297
|
+
self._audit.log_stored(event)
|
|
298
|
+
|
|
299
|
+
@staticmethod
|
|
300
|
+
def _violation_notification(
|
|
301
|
+
job: ScanJob,
|
|
302
|
+
group: _Group,
|
|
303
|
+
account_id: int,
|
|
304
|
+
notification_type: NotificationType,
|
|
305
|
+
rule_id: str,
|
|
306
|
+
violation_ids: list[str],
|
|
307
|
+
*,
|
|
308
|
+
monitored: bool = False,
|
|
309
|
+
aggregated: bool = False,
|
|
310
|
+
) -> NotificationEvent:
|
|
311
|
+
severity = (
|
|
312
|
+
Severity.CRITICAL
|
|
313
|
+
if notification_type is NotificationType.CRITICAL_VIOLATION
|
|
314
|
+
else Severity.HIGH
|
|
315
|
+
)
|
|
316
|
+
where = {
|
|
317
|
+
"pull_request": f"pull request {group.label}",
|
|
318
|
+
"merge_group": group.label,
|
|
319
|
+
}.get(group.kind, f"branch {group.label}")
|
|
320
|
+
count = len(violation_ids)
|
|
321
|
+
return NotificationEvent(
|
|
322
|
+
type=notification_type,
|
|
323
|
+
account_id=account_id,
|
|
324
|
+
severity=severity,
|
|
325
|
+
installation_id=job.installation_id,
|
|
326
|
+
repository_id=job.repository.id,
|
|
327
|
+
resource_type="violation",
|
|
328
|
+
resource_id=violation_ids[0],
|
|
329
|
+
dedup_key=domain_key(
|
|
330
|
+
notification_type,
|
|
331
|
+
job.installation_id,
|
|
332
|
+
job.repository.id,
|
|
333
|
+
group.key,
|
|
334
|
+
rule_id,
|
|
335
|
+
),
|
|
336
|
+
title=(
|
|
337
|
+
f"Would block (monitor mode): {rule_id} in {job.repository.full_name}"
|
|
338
|
+
if monitored
|
|
339
|
+
else f"Blocked: {rule_id} in {job.repository.full_name}"
|
|
340
|
+
),
|
|
341
|
+
body=(
|
|
342
|
+
(
|
|
343
|
+
f"CommitGuard found {count} commit(s) in {where} of "
|
|
344
|
+
f"{job.repository.full_name} ({rule_id}, {severity.value}) at "
|
|
345
|
+
f"{job.head_sha[:12]} that policy blocks. The repository is in monitor mode, "
|
|
346
|
+
"so the GitHub check reports a warning instead of failing."
|
|
347
|
+
)
|
|
348
|
+
if monitored
|
|
349
|
+
else (
|
|
350
|
+
f"CommitGuard blocked {count} commit(s) in {where} of "
|
|
351
|
+
f"{job.repository.full_name} ({rule_id}, {severity.value}) at "
|
|
352
|
+
f"{job.head_sha[:12]}. The GitHub check failed; remediation is shown on the "
|
|
353
|
+
"violation page."
|
|
354
|
+
)
|
|
355
|
+
),
|
|
356
|
+
metadata={
|
|
357
|
+
"rule": rule_id,
|
|
358
|
+
"violations": count,
|
|
359
|
+
"violation_ids": ",".join(violation_ids[:MAX_NOTIFIED_VIOLATIONS]),
|
|
360
|
+
"scan": job.job_id,
|
|
361
|
+
"head_sha": job.head_sha,
|
|
362
|
+
"source": group.kind,
|
|
363
|
+
"location": where,
|
|
364
|
+
"monitor_mode": monitored,
|
|
365
|
+
# Organization digest enabled: e-mail and webhooks go to the digest instead.
|
|
366
|
+
"aggregated": aggregated,
|
|
367
|
+
},
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# ------------------------------------------------------------------ #
|
|
371
|
+
# Merge queue
|
|
372
|
+
# ------------------------------------------------------------------ #
|
|
373
|
+
def merge_group_destroyed(
|
|
374
|
+
self,
|
|
375
|
+
installation_id: int,
|
|
376
|
+
repository_id: int,
|
|
377
|
+
head_sha: str,
|
|
378
|
+
*,
|
|
379
|
+
reason: str | None,
|
|
380
|
+
base_ref: str,
|
|
381
|
+
) -> None:
|
|
382
|
+
"""End merge group exposures; a merged group's violations move to the base branch."""
|
|
383
|
+
now = self._now()
|
|
384
|
+
key = merge_group_key(head_sha)
|
|
385
|
+
merged = reason == "merged"
|
|
386
|
+
text = (
|
|
387
|
+
f"merge group {head_sha[:12]} was merged into {clean_text(base_ref, 200)}"
|
|
388
|
+
if merged
|
|
389
|
+
else f"merge group {head_sha[:12]} was {reason or 'removed'}"
|
|
390
|
+
)
|
|
391
|
+
events: list[AuditEvent] = []
|
|
392
|
+
with self._store.transaction() as db:
|
|
393
|
+
rows = db.execute(
|
|
394
|
+
"SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
|
|
395
|
+
"AND repository_id = ? AND group_key = ? AND active = 1",
|
|
396
|
+
(installation_id, repository_id, key),
|
|
397
|
+
).fetchall()
|
|
398
|
+
ids = {row["violation_id"] for row in rows}
|
|
399
|
+
db.execute(
|
|
400
|
+
"UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ? "
|
|
401
|
+
"WHERE installation_id = ? AND repository_id = ? AND group_key = ? AND active = 1",
|
|
402
|
+
(_ts(now), text, installation_id, repository_id, key),
|
|
403
|
+
)
|
|
404
|
+
if merged:
|
|
405
|
+
branch = _Group(branch_group_key(base_ref), "branch", clean_text(base_ref, 256))
|
|
406
|
+
for violation_id in ids:
|
|
407
|
+
self._activate_exposure_row(
|
|
408
|
+
db, installation_id, repository_id, branch, violation_id, None, now
|
|
409
|
+
)
|
|
410
|
+
events.extend(self._refresh_statuses(db, ids, now))
|
|
411
|
+
events = [self._store.insert_audit_event(db, e) for e in events]
|
|
412
|
+
for event in events:
|
|
413
|
+
self._audit.log_stored(event)
|
|
414
|
+
|
|
415
|
+
# ------------------------------------------------------------------ #
|
|
416
|
+
# GitHub lifecycle events
|
|
417
|
+
# ------------------------------------------------------------------ #
|
|
418
|
+
def pull_request_closed(
|
|
419
|
+
self,
|
|
420
|
+
installation_id: int,
|
|
421
|
+
repository_id: int,
|
|
422
|
+
number: int,
|
|
423
|
+
*,
|
|
424
|
+
merged: bool,
|
|
425
|
+
base_ref: str | None,
|
|
426
|
+
) -> None:
|
|
427
|
+
now = self._now()
|
|
428
|
+
key = group_key(number)
|
|
429
|
+
reason = (
|
|
430
|
+
f"pull request #{number} was merged into {clean_text(base_ref or 'its base', 200)}"
|
|
431
|
+
if merged
|
|
432
|
+
else _closed_reason(number)
|
|
433
|
+
)
|
|
434
|
+
events: list[AuditEvent] = []
|
|
435
|
+
with self._store.transaction() as db:
|
|
436
|
+
rows = db.execute(
|
|
437
|
+
"SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
|
|
438
|
+
"AND repository_id = ? AND group_key = ? AND active = 1",
|
|
439
|
+
(installation_id, repository_id, key),
|
|
440
|
+
).fetchall()
|
|
441
|
+
ids = {row["violation_id"] for row in rows}
|
|
442
|
+
db.execute(
|
|
443
|
+
"UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ? "
|
|
444
|
+
"WHERE installation_id = ? AND repository_id = ? AND group_key = ? AND active = 1",
|
|
445
|
+
(_ts(now), reason, installation_id, repository_id, key),
|
|
446
|
+
)
|
|
447
|
+
if merged and base_ref:
|
|
448
|
+
branch = _Group(branch_group_key(base_ref), "branch", clean_text(base_ref, 256))
|
|
449
|
+
for violation_id in ids:
|
|
450
|
+
self._activate_exposure_row(
|
|
451
|
+
db, installation_id, repository_id, branch, violation_id, None, now
|
|
452
|
+
)
|
|
453
|
+
events.extend(self._refresh_statuses(db, ids, now))
|
|
454
|
+
events = [self._store.insert_audit_event(db, e) for e in events]
|
|
455
|
+
for event in events:
|
|
456
|
+
self._audit.log_stored(event)
|
|
457
|
+
|
|
458
|
+
def pull_request_reopened(self, installation_id: int, repository_id: int, number: int) -> None:
|
|
459
|
+
"""Restore exposures ended by closing a pull request whose commits did not change."""
|
|
460
|
+
now = self._now()
|
|
461
|
+
key = group_key(number)
|
|
462
|
+
events: list[AuditEvent] = []
|
|
463
|
+
with self._store.transaction() as db:
|
|
464
|
+
rows = db.execute(
|
|
465
|
+
"SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
|
|
466
|
+
"AND repository_id = ? AND group_key = ? AND active = 0 AND closed_reason = ?",
|
|
467
|
+
(installation_id, repository_id, key, _closed_reason(number)),
|
|
468
|
+
).fetchall()
|
|
469
|
+
ids = {row["violation_id"] for row in rows}
|
|
470
|
+
db.execute(
|
|
471
|
+
"UPDATE violation_exposures SET active = 1, closed_at = NULL, "
|
|
472
|
+
"closed_reason = NULL, opened_at = ? WHERE installation_id = ? "
|
|
473
|
+
"AND repository_id = ? AND group_key = ? AND active = 0 AND closed_reason = ?",
|
|
474
|
+
(_ts(now), installation_id, repository_id, key, _closed_reason(number)),
|
|
475
|
+
)
|
|
476
|
+
events.extend(self._refresh_statuses(db, ids, now))
|
|
477
|
+
events = [self._store.insert_audit_event(db, e) for e in events]
|
|
478
|
+
for event in events:
|
|
479
|
+
self._audit.log_stored(event)
|
|
480
|
+
|
|
481
|
+
def branch_deleted(self, installation_id: int, repository_id: int, ref: str) -> None:
|
|
482
|
+
now = self._now()
|
|
483
|
+
key = branch_group_key(ref)
|
|
484
|
+
reason = f"branch {clean_text(ref, 200)} was deleted"
|
|
485
|
+
events: list[AuditEvent] = []
|
|
486
|
+
with self._store.transaction() as db:
|
|
487
|
+
rows = db.execute(
|
|
488
|
+
"SELECT violation_id FROM violation_exposures WHERE installation_id = ? "
|
|
489
|
+
"AND repository_id = ? AND group_key = ? AND active = 1",
|
|
490
|
+
(installation_id, repository_id, key),
|
|
491
|
+
).fetchall()
|
|
492
|
+
ids = {row["violation_id"] for row in rows}
|
|
493
|
+
db.execute(
|
|
494
|
+
"UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ? "
|
|
495
|
+
"WHERE installation_id = ? AND repository_id = ? AND group_key = ? AND active = 1",
|
|
496
|
+
(_ts(now), reason, installation_id, repository_id, key),
|
|
497
|
+
)
|
|
498
|
+
events.extend(self._refresh_statuses(db, ids, now))
|
|
499
|
+
events = [self._store.insert_audit_event(db, e) for e in events]
|
|
500
|
+
for event in events:
|
|
501
|
+
self._audit.log_stored(event)
|
|
502
|
+
|
|
503
|
+
# ------------------------------------------------------------------ #
|
|
504
|
+
# Internals
|
|
505
|
+
# ------------------------------------------------------------------ #
|
|
506
|
+
@staticmethod
|
|
507
|
+
def _identities(
|
|
508
|
+
repository: Repository | None, shas: Iterable[str | None]
|
|
509
|
+
) -> dict[str, tuple[str, str]]:
|
|
510
|
+
wanted = sorted(s for s in shas if s)[:MAX_IDENTITY_LOOKUPS]
|
|
511
|
+
if repository is None or not wanted:
|
|
512
|
+
return {}
|
|
513
|
+
identities: dict[str, tuple[str, str]] = {}
|
|
514
|
+
try:
|
|
515
|
+
for commit in repository.iter_commits(wanted):
|
|
516
|
+
if commit.sha:
|
|
517
|
+
identities[commit.sha] = (
|
|
518
|
+
clean_text(str(commit.author), MAX_IDENTITY_CHARS),
|
|
519
|
+
clean_text(str(commit.committer), MAX_IDENTITY_CHARS),
|
|
520
|
+
)
|
|
521
|
+
except Exception as exc: # noqa: BLE001 - identities are display data only
|
|
522
|
+
log.warning("finding_identities_unavailable", error_type=type(exc).__name__)
|
|
523
|
+
return identities
|
|
524
|
+
|
|
525
|
+
def _unreachable_branch_exposures(
|
|
526
|
+
self,
|
|
527
|
+
job: ScanJob,
|
|
528
|
+
group: _Group,
|
|
529
|
+
current: set[str],
|
|
530
|
+
repository: Repository | None,
|
|
531
|
+
) -> set[str]:
|
|
532
|
+
"""Active branch exposures whose commit is provably no longer on the branch."""
|
|
533
|
+
if group.kind != "branch" or repository is None:
|
|
534
|
+
return set()
|
|
535
|
+
rows = self._store.query(
|
|
536
|
+
"SELECT e.violation_id, v.fingerprint, v.commit_sha FROM violation_exposures e "
|
|
537
|
+
"JOIN violations v ON v.violation_id = e.violation_id WHERE e.installation_id = ? "
|
|
538
|
+
"AND e.repository_id = ? AND e.group_key = ? AND e.active = 1 LIMIT 1000",
|
|
539
|
+
(job.installation_id, job.repository.id, group.key),
|
|
540
|
+
)
|
|
541
|
+
gone: set[str] = set()
|
|
542
|
+
for row in rows:
|
|
543
|
+
if row["fingerprint"] in current or not row["commit_sha"]:
|
|
544
|
+
continue
|
|
545
|
+
try:
|
|
546
|
+
reachable = repository.is_ancestor(row["commit_sha"], job.head_sha)
|
|
547
|
+
except Exception as exc: # noqa: BLE001 - unknown keeps the exposure open
|
|
548
|
+
log.warning("reachability_check_failed", error_type=type(exc).__name__)
|
|
549
|
+
reachable = None
|
|
550
|
+
if reachable is False:
|
|
551
|
+
gone.add(row["violation_id"])
|
|
552
|
+
return gone
|
|
553
|
+
|
|
554
|
+
@staticmethod
|
|
555
|
+
def _newer_scan_completed(db: sqlite3.Connection, job: ScanJob) -> bool:
|
|
556
|
+
row = db.execute(
|
|
557
|
+
"SELECT 1 FROM scan_jobs WHERE installation_id = ? AND repository_id = ? "
|
|
558
|
+
"AND group_key = ? AND sequence > ? AND state IN ('passed', 'failed') LIMIT 1",
|
|
559
|
+
(job.installation_id, job.repository.id, job.group_key, job.sequence),
|
|
560
|
+
).fetchone()
|
|
561
|
+
return row is not None
|
|
562
|
+
|
|
563
|
+
def _upsert_violation(
|
|
564
|
+
self,
|
|
565
|
+
db: sqlite3.Connection,
|
|
566
|
+
job: ScanJob,
|
|
567
|
+
item: EvaluatedFinding,
|
|
568
|
+
identities: dict[str, tuple[str, str]],
|
|
569
|
+
now: datetime,
|
|
570
|
+
*,
|
|
571
|
+
stale: bool,
|
|
572
|
+
) -> tuple[str, AuditEvent | None]:
|
|
573
|
+
finding = item.finding
|
|
574
|
+
author = identities.get(finding.commit_sha or "", (None, None))[0]
|
|
575
|
+
row = db.execute(
|
|
576
|
+
"SELECT violation_id, status FROM violations WHERE installation_id = ? "
|
|
577
|
+
"AND repository_id = ? AND fingerprint = ?",
|
|
578
|
+
(job.installation_id, job.repository.id, item.fingerprint),
|
|
579
|
+
).fetchone()
|
|
580
|
+
common: dict[str, Any] = {
|
|
581
|
+
"installation_id": job.installation_id,
|
|
582
|
+
"repository_id": job.repository.id,
|
|
583
|
+
"repository": job.repository.full_name,
|
|
584
|
+
"head_sha": job.head_sha,
|
|
585
|
+
"action": item.action,
|
|
586
|
+
}
|
|
587
|
+
if row is None:
|
|
588
|
+
violation_id = uuid.uuid4().hex
|
|
589
|
+
db.execute(
|
|
590
|
+
"INSERT INTO violations (violation_id, installation_id, repository_id, "
|
|
591
|
+
"fingerprint, "
|
|
592
|
+
"rule_id, detector, severity, severity_rank, action, title, commit_sha, author, "
|
|
593
|
+
"status, first_detected_at, last_detected_at, first_job_id, last_job_id, "
|
|
594
|
+
"detections, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, "
|
|
595
|
+
"?, ?, 1, ?)",
|
|
596
|
+
(
|
|
597
|
+
violation_id,
|
|
598
|
+
job.installation_id,
|
|
599
|
+
job.repository.id,
|
|
600
|
+
item.fingerprint,
|
|
601
|
+
finding.rule_id,
|
|
602
|
+
finding.detector,
|
|
603
|
+
finding.severity.value,
|
|
604
|
+
finding.severity.rank,
|
|
605
|
+
item.action.value,
|
|
606
|
+
clean_text(finding.title, 200),
|
|
607
|
+
finding.commit_sha,
|
|
608
|
+
author,
|
|
609
|
+
_ts(now),
|
|
610
|
+
_ts(now),
|
|
611
|
+
job.job_id,
|
|
612
|
+
job.job_id,
|
|
613
|
+
_ts(now),
|
|
614
|
+
),
|
|
615
|
+
)
|
|
616
|
+
return violation_id, self._audit.build(
|
|
617
|
+
AuditEventType.VIOLATION_OPENED,
|
|
618
|
+
**common,
|
|
619
|
+
violation=violation_id,
|
|
620
|
+
rule=finding.rule_id,
|
|
621
|
+
severity=finding.severity.value,
|
|
622
|
+
)
|
|
623
|
+
violation_id = row["violation_id"]
|
|
624
|
+
reopen = row["status"] == "resolved" and not stale
|
|
625
|
+
db.execute(
|
|
626
|
+
"UPDATE violations SET last_detected_at = ?, last_job_id = ?, "
|
|
627
|
+
"detections = detections + 1, action = ?, severity = ?, severity_rank = ?, "
|
|
628
|
+
"title = ?, author = COALESCE(?, author), updated_at = ? WHERE violation_id = ?",
|
|
629
|
+
(
|
|
630
|
+
_ts(now),
|
|
631
|
+
job.job_id,
|
|
632
|
+
item.action.value,
|
|
633
|
+
finding.severity.value,
|
|
634
|
+
finding.severity.rank,
|
|
635
|
+
clean_text(finding.title, 200),
|
|
636
|
+
author,
|
|
637
|
+
_ts(now),
|
|
638
|
+
violation_id,
|
|
639
|
+
),
|
|
640
|
+
)
|
|
641
|
+
if not reopen:
|
|
642
|
+
return violation_id, None
|
|
643
|
+
db.execute(
|
|
644
|
+
"UPDATE violations SET status = 'open', resolved_at = NULL, resolution = NULL, "
|
|
645
|
+
"acknowledged_at = NULL, acknowledged_by_id = NULL, acknowledged_by_login = NULL, "
|
|
646
|
+
"acknowledgement_note = NULL WHERE violation_id = ?",
|
|
647
|
+
(violation_id,),
|
|
648
|
+
)
|
|
649
|
+
return violation_id, self._audit.build(
|
|
650
|
+
AuditEventType.VIOLATION_REOPENED,
|
|
651
|
+
**common,
|
|
652
|
+
violation=violation_id,
|
|
653
|
+
rule=finding.rule_id,
|
|
654
|
+
)
|
|
655
|
+
|
|
656
|
+
def _activate_exposure(
|
|
657
|
+
self,
|
|
658
|
+
db: sqlite3.Connection,
|
|
659
|
+
job: ScanJob,
|
|
660
|
+
group: _Group,
|
|
661
|
+
violation_id: str,
|
|
662
|
+
now: datetime,
|
|
663
|
+
) -> None:
|
|
664
|
+
self._activate_exposure_row(
|
|
665
|
+
db, job.installation_id, job.repository.id, group, violation_id, job.job_id, now
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def _activate_exposure_row(
|
|
670
|
+
db: sqlite3.Connection,
|
|
671
|
+
installation_id: int,
|
|
672
|
+
repository_id: int,
|
|
673
|
+
group: _Group,
|
|
674
|
+
violation_id: str,
|
|
675
|
+
job_id: str | None,
|
|
676
|
+
now: datetime,
|
|
677
|
+
) -> None:
|
|
678
|
+
db.execute(
|
|
679
|
+
"INSERT INTO violation_exposures (violation_id, installation_id, repository_id, "
|
|
680
|
+
"group_key, kind, label, active, first_job_id, last_job_id, opened_at) "
|
|
681
|
+
"VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?, ?) ON CONFLICT (violation_id, group_key) DO UPDATE "
|
|
682
|
+
"SET active = 1, last_job_id = COALESCE(excluded.last_job_id, last_job_id), "
|
|
683
|
+
"opened_at = CASE WHEN active = 1 THEN opened_at ELSE excluded.opened_at END, "
|
|
684
|
+
"closed_at = NULL, closed_reason = NULL",
|
|
685
|
+
(
|
|
686
|
+
violation_id,
|
|
687
|
+
installation_id,
|
|
688
|
+
repository_id,
|
|
689
|
+
group.key,
|
|
690
|
+
group.kind,
|
|
691
|
+
group.label,
|
|
692
|
+
job_id,
|
|
693
|
+
job_id,
|
|
694
|
+
_ts(now),
|
|
695
|
+
),
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
@staticmethod
|
|
699
|
+
def _close_absent_exposures(
|
|
700
|
+
db: sqlite3.Connection,
|
|
701
|
+
job: ScanJob,
|
|
702
|
+
group: _Group,
|
|
703
|
+
current: set[str],
|
|
704
|
+
unreachable: set[str],
|
|
705
|
+
now: datetime,
|
|
706
|
+
) -> set[str]:
|
|
707
|
+
rows = db.execute(
|
|
708
|
+
"SELECT e.violation_id, v.fingerprint FROM violation_exposures e JOIN violations v "
|
|
709
|
+
"ON v.violation_id = e.violation_id WHERE e.installation_id = ? "
|
|
710
|
+
"AND e.repository_id = ? AND e.group_key = ? AND e.active = 1",
|
|
711
|
+
(job.installation_id, job.repository.id, group.key),
|
|
712
|
+
).fetchall()
|
|
713
|
+
closed: set[str] = set()
|
|
714
|
+
for row in rows:
|
|
715
|
+
if row["fingerprint"] in current:
|
|
716
|
+
continue
|
|
717
|
+
if group.kind == "pull_request":
|
|
718
|
+
reason = (
|
|
719
|
+
f"no longer part of pull request {group.label} (scan of {job.head_sha[:12]})"
|
|
720
|
+
)
|
|
721
|
+
elif row["violation_id"] in unreachable:
|
|
722
|
+
reason = (
|
|
723
|
+
f"commit no longer reachable from {group.label} at {job.head_sha[:12]} "
|
|
724
|
+
"(history rewritten)"
|
|
725
|
+
)
|
|
726
|
+
else:
|
|
727
|
+
continue
|
|
728
|
+
db.execute(
|
|
729
|
+
"UPDATE violation_exposures SET active = 0, closed_at = ?, closed_reason = ?, "
|
|
730
|
+
"last_job_id = ? WHERE violation_id = ? AND group_key = ?",
|
|
731
|
+
(_ts(now), reason, job.job_id, row["violation_id"], group.key),
|
|
732
|
+
)
|
|
733
|
+
closed.add(row["violation_id"])
|
|
734
|
+
return closed
|
|
735
|
+
|
|
736
|
+
def _refresh_statuses(
|
|
737
|
+
self, db: sqlite3.Connection, violation_ids: Iterable[str], now: datetime
|
|
738
|
+
) -> list[AuditEvent]:
|
|
739
|
+
events: list[AuditEvent] = []
|
|
740
|
+
for violation_id in sorted(violation_ids):
|
|
741
|
+
row = db.execute(
|
|
742
|
+
"SELECT v.status, v.installation_id, v.repository_id, v.rule_id, v.action, "
|
|
743
|
+
"(SELECT COUNT(*) FROM violation_exposures e WHERE e.violation_id = v.violation_id "
|
|
744
|
+
"AND e.active = 1) AS active, (SELECT closed_reason FROM violation_exposures e "
|
|
745
|
+
"WHERE e.violation_id = v.violation_id AND e.active = 0 ORDER BY closed_at DESC "
|
|
746
|
+
"LIMIT 1) AS reason FROM violations v WHERE v.violation_id = ?",
|
|
747
|
+
(violation_id,),
|
|
748
|
+
).fetchone()
|
|
749
|
+
if row is None:
|
|
750
|
+
continue
|
|
751
|
+
if row["active"] == 0 and row["status"] == "open":
|
|
752
|
+
resolution = row["reason"] or "not present in a newer scan"
|
|
753
|
+
db.execute(
|
|
754
|
+
"UPDATE violations SET status = 'resolved', resolved_at = ?, resolution = ?, "
|
|
755
|
+
"updated_at = ? WHERE violation_id = ?",
|
|
756
|
+
(_ts(now), resolution, _ts(now), violation_id),
|
|
757
|
+
)
|
|
758
|
+
events.append(
|
|
759
|
+
self._audit.build(
|
|
760
|
+
AuditEventType.VIOLATION_RESOLVED,
|
|
761
|
+
installation_id=row["installation_id"],
|
|
762
|
+
repository_id=row["repository_id"],
|
|
763
|
+
action=Action(row["action"]),
|
|
764
|
+
violation=violation_id,
|
|
765
|
+
rule=row["rule_id"],
|
|
766
|
+
reason=resolution,
|
|
767
|
+
)
|
|
768
|
+
)
|
|
769
|
+
elif row["active"] > 0 and row["status"] == "resolved":
|
|
770
|
+
db.execute(
|
|
771
|
+
"UPDATE violations SET status = 'open', resolved_at = NULL, resolution = NULL, "
|
|
772
|
+
"acknowledged_at = NULL, acknowledged_by_id = NULL, "
|
|
773
|
+
"acknowledged_by_login = NULL, acknowledgement_note = NULL, updated_at = ? "
|
|
774
|
+
"WHERE violation_id = ?",
|
|
775
|
+
(_ts(now), violation_id),
|
|
776
|
+
)
|
|
777
|
+
events.append(
|
|
778
|
+
self._audit.build(
|
|
779
|
+
AuditEventType.VIOLATION_REOPENED,
|
|
780
|
+
installation_id=row["installation_id"],
|
|
781
|
+
repository_id=row["repository_id"],
|
|
782
|
+
action=Action(row["action"]),
|
|
783
|
+
violation=violation_id,
|
|
784
|
+
rule=row["rule_id"],
|
|
785
|
+
)
|
|
786
|
+
)
|
|
787
|
+
return events
|
|
788
|
+
|
|
789
|
+
@staticmethod
|
|
790
|
+
def _insert_finding(
|
|
791
|
+
db: sqlite3.Connection,
|
|
792
|
+
job: ScanJob,
|
|
793
|
+
item: EvaluatedFinding,
|
|
794
|
+
violation_id: str | None,
|
|
795
|
+
author: str | None,
|
|
796
|
+
committer: str | None,
|
|
797
|
+
now: datetime,
|
|
798
|
+
) -> None:
|
|
799
|
+
finding = item.finding
|
|
800
|
+
db.execute(
|
|
801
|
+
"INSERT INTO findings (job_id, installation_id, repository_id, violation_id, "
|
|
802
|
+
"fingerprint, commit_sha, rule_id, detector, severity, severity_rank, confidence, "
|
|
803
|
+
"action, policy_id, reason, title, message, remediation, evidence, author, committer, "
|
|
804
|
+
"created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
805
|
+
(
|
|
806
|
+
job.job_id,
|
|
807
|
+
job.installation_id,
|
|
808
|
+
job.repository.id,
|
|
809
|
+
violation_id,
|
|
810
|
+
item.fingerprint,
|
|
811
|
+
finding.commit_sha,
|
|
812
|
+
finding.rule_id,
|
|
813
|
+
finding.detector,
|
|
814
|
+
finding.severity.value,
|
|
815
|
+
finding.severity.rank,
|
|
816
|
+
finding.confidence.value,
|
|
817
|
+
item.action.value,
|
|
818
|
+
item.policy_id,
|
|
819
|
+
clean_text(item.reason, 500),
|
|
820
|
+
clean_text(finding.title, 200),
|
|
821
|
+
clean_text(finding.message, MAX_TEXT_CHARS),
|
|
822
|
+
clean_text(finding.remediation, MAX_TEXT_CHARS),
|
|
823
|
+
_evidence_document(item),
|
|
824
|
+
author,
|
|
825
|
+
committer,
|
|
826
|
+
_ts(now),
|
|
827
|
+
),
|
|
828
|
+
)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _aggregates_violations(db: sqlite3.Connection, account_id: int) -> bool:
|
|
832
|
+
row = db.execute(
|
|
833
|
+
"SELECT document FROM organization_settings WHERE account_id = ?", (account_id,)
|
|
834
|
+
).fetchone()
|
|
835
|
+
if row is None:
|
|
836
|
+
return False
|
|
837
|
+
try:
|
|
838
|
+
return bool(json.loads(str(row["document"])).get("aggregate_violation_alerts", False))
|
|
839
|
+
except ValueError:
|
|
840
|
+
return False
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _violation_digest(
|
|
844
|
+
db: sqlite3.Connection, event: NotificationEvent, now: datetime
|
|
845
|
+
) -> NotificationEvent:
|
|
846
|
+
"""One organization-level alert per rule and hour, however many repositories."""
|
|
847
|
+
rule = str(event.metadata.get("rule", ""))
|
|
848
|
+
window = NotificationType.VIOLATION_DIGEST
|
|
849
|
+
since = now.timestamp() - (now.timestamp() % 3600)
|
|
850
|
+
repositories = db.execute(
|
|
851
|
+
"SELECT COUNT(DISTINCT repository_id) AS n FROM notification_events WHERE account_id = ? "
|
|
852
|
+
"AND type IN ('critical_violation', 'high_violation') AND last_occurred_at >= ? "
|
|
853
|
+
"AND json_extract(metadata, '$.rule') = ?",
|
|
854
|
+
(event.account_id, since, rule),
|
|
855
|
+
).fetchone()["n"]
|
|
856
|
+
count = max(int(repositories or 0), 1)
|
|
857
|
+
return NotificationEvent(
|
|
858
|
+
type=window,
|
|
859
|
+
account_id=event.account_id,
|
|
860
|
+
severity=event.severity,
|
|
861
|
+
resource_type="organization",
|
|
862
|
+
resource_id=str(event.account_id),
|
|
863
|
+
dedup_key=domain_key(window, event.account_id, rule),
|
|
864
|
+
title=f"CommitGuard detected blocked violations of {rule} in {count} repositories",
|
|
865
|
+
body=(
|
|
866
|
+
f"Violations of {rule} ({event.severity.value}) were detected in {count} "
|
|
867
|
+
"repository(ies) of your organization in the last hour. E-mail and webhooks are "
|
|
868
|
+
"aggregated; the dashboard lists every repository and violation."
|
|
869
|
+
),
|
|
870
|
+
metadata={"rule": rule, "repositories": count},
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def merge_queue_failure(job: ScanJob, account_id: int, body: str) -> NotificationEvent:
|
|
875
|
+
return NotificationEvent(
|
|
876
|
+
type=NotificationType.MERGE_QUEUE_FAILURE,
|
|
877
|
+
account_id=account_id,
|
|
878
|
+
severity=Severity.HIGH,
|
|
879
|
+
installation_id=job.installation_id,
|
|
880
|
+
repository_id=job.repository.id,
|
|
881
|
+
resource_type="scan",
|
|
882
|
+
resource_id=job.job_id,
|
|
883
|
+
dedup_key=domain_key(
|
|
884
|
+
NotificationType.MERGE_QUEUE_FAILURE,
|
|
885
|
+
job.installation_id,
|
|
886
|
+
job.repository.id,
|
|
887
|
+
job.head_sha,
|
|
888
|
+
),
|
|
889
|
+
title=f"Merge queue blocked in {job.repository.full_name}",
|
|
890
|
+
body=f"{body} Merge group commit {job.head_sha[:12]}.",
|
|
891
|
+
metadata={"scan": job.job_id, "head_sha": job.head_sha, "source": "merge_queue"},
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
def scan_result_label(
|
|
896
|
+
state: str, result_action: str | None, failure_kind: str | None = None
|
|
897
|
+
) -> str:
|
|
898
|
+
"""The dashboard's scan result for a stored job state (see ``views.ScanResultStatus``)."""
|
|
899
|
+
if state == JobState.CANCELLED.value and failure_kind == "stale":
|
|
900
|
+
return "stale"
|
|
901
|
+
if state == JobState.PASSED.value:
|
|
902
|
+
return "warning" if result_action == Action.WARN.value else "pass"
|
|
903
|
+
return {
|
|
904
|
+
JobState.QUEUED.value: "queued",
|
|
905
|
+
JobState.RUNNING.value: "running",
|
|
906
|
+
JobState.FAILED.value: "blocked",
|
|
907
|
+
JobState.ERROR.value: "error",
|
|
908
|
+
JobState.CANCELLED.value: "cancelled",
|
|
909
|
+
}.get(state, "error")
|