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,201 @@
|
|
|
1
|
+
"""Outbox dispatcher: notification events -> inbox entries and delivery records.
|
|
2
|
+
|
|
3
|
+
Each pending event is claimed with a conditional update (``dispatched_at IS
|
|
4
|
+
NULL``) inside the same transaction that writes its in-app notifications,
|
|
5
|
+
e-mail and webhook delivery records and the ``notification_created`` audit
|
|
6
|
+
event. Several processes can run the dispatcher: only one claims an event, and
|
|
7
|
+
a crash before commit leaves the event pending for the next run.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import sqlite3
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
|
|
16
|
+
from commitguard.audit.models import AuditEventType
|
|
17
|
+
from commitguard.core.result import Severity
|
|
18
|
+
from commitguard.github.storage import SqliteStateStore
|
|
19
|
+
from commitguard.notifications.channels import in_app
|
|
20
|
+
from commitguard.notifications.deduplication import delivery_idempotency_key
|
|
21
|
+
from commitguard.notifications.models import (
|
|
22
|
+
DEFINITIONS,
|
|
23
|
+
DeliveryStatus,
|
|
24
|
+
NotificationChannel,
|
|
25
|
+
NotificationEvent,
|
|
26
|
+
NotificationType,
|
|
27
|
+
StoredNotificationEvent,
|
|
28
|
+
)
|
|
29
|
+
from commitguard.notifications.preferences import load_organization_settings
|
|
30
|
+
from commitguard.notifications.settings import NotificationSettings
|
|
31
|
+
from commitguard.observability.logging import correlation, get_logger
|
|
32
|
+
from commitguard.observability.metrics import NOTIFICATIONS_CREATED, Metrics
|
|
33
|
+
from commitguard.services.audit import AuditService
|
|
34
|
+
|
|
35
|
+
log = get_logger(__name__)
|
|
36
|
+
|
|
37
|
+
DISPATCH_BATCH = 100
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _dt(value: float | None) -> datetime | None:
|
|
41
|
+
return None if value is None else datetime.fromtimestamp(float(value), UTC)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def stored_event(row: sqlite3.Row) -> StoredNotificationEvent:
|
|
45
|
+
event = NotificationEvent.model_construct(
|
|
46
|
+
event_id=row["event_id"],
|
|
47
|
+
type=NotificationType(row["type"]),
|
|
48
|
+
account_id=int(row["account_id"]),
|
|
49
|
+
severity=Severity(row["severity"]),
|
|
50
|
+
installation_id=row["installation_id"],
|
|
51
|
+
repository_id=row["repository_id"],
|
|
52
|
+
resource_type=row["resource_type"],
|
|
53
|
+
resource_id=row["resource_id"],
|
|
54
|
+
dedup_key=row["dedup_key"],
|
|
55
|
+
title=row["title"],
|
|
56
|
+
body=row["body"],
|
|
57
|
+
metadata=json.loads(row["metadata"] or "{}"),
|
|
58
|
+
)
|
|
59
|
+
return StoredNotificationEvent(
|
|
60
|
+
event=event,
|
|
61
|
+
occurrences=int(row["occurrences"]),
|
|
62
|
+
created_at=datetime.fromtimestamp(float(row["created_at"]), UTC),
|
|
63
|
+
last_occurred_at=datetime.fromtimestamp(float(row["last_occurred_at"]), UTC),
|
|
64
|
+
dispatched_at=_dt(row["dispatched_at"]),
|
|
65
|
+
request_id=row["request_id"],
|
|
66
|
+
delivery_id=row["delivery_id"],
|
|
67
|
+
job_id=row["job_id"],
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class NotificationDispatcher:
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
store: SqliteStateStore,
|
|
75
|
+
audit: AuditService,
|
|
76
|
+
metrics: Metrics,
|
|
77
|
+
settings: NotificationSettings,
|
|
78
|
+
*,
|
|
79
|
+
now: Callable[[], datetime] = lambda: datetime.now(UTC),
|
|
80
|
+
) -> None:
|
|
81
|
+
self._store = store
|
|
82
|
+
self._audit = audit
|
|
83
|
+
self._metrics = metrics
|
|
84
|
+
self._settings = settings
|
|
85
|
+
self._now = now
|
|
86
|
+
|
|
87
|
+
def dispatch_pending(self, limit: int = DISPATCH_BATCH) -> int:
|
|
88
|
+
rows = self._store.query(
|
|
89
|
+
"SELECT event_id FROM notification_events WHERE dispatched_at IS NULL "
|
|
90
|
+
"ORDER BY created_at LIMIT ?",
|
|
91
|
+
(int(limit),),
|
|
92
|
+
)
|
|
93
|
+
dispatched = 0
|
|
94
|
+
for row in rows:
|
|
95
|
+
with correlation(notification_event_id=row["event_id"]):
|
|
96
|
+
if self._dispatch(str(row["event_id"])):
|
|
97
|
+
dispatched += 1
|
|
98
|
+
return dispatched
|
|
99
|
+
|
|
100
|
+
def _dispatch(self, event_id: str) -> bool:
|
|
101
|
+
now = self._now()
|
|
102
|
+
audit_event = None
|
|
103
|
+
with self._store.transaction() as db:
|
|
104
|
+
claimed = db.execute(
|
|
105
|
+
"UPDATE notification_events SET dispatched_at = ? WHERE event_id = ? "
|
|
106
|
+
"AND dispatched_at IS NULL",
|
|
107
|
+
(now.timestamp(), event_id),
|
|
108
|
+
).rowcount
|
|
109
|
+
if claimed != 1:
|
|
110
|
+
return False
|
|
111
|
+
row = db.execute(
|
|
112
|
+
"SELECT * FROM notification_events WHERE event_id = ?", (event_id,)
|
|
113
|
+
).fetchone()
|
|
114
|
+
stored = stored_event(row)
|
|
115
|
+
event = stored.event
|
|
116
|
+
definition = DEFINITIONS[event.type]
|
|
117
|
+
organization = load_organization_settings(db, event.account_id)
|
|
118
|
+
users = in_app.recipients(db, event.account_id, definition, organization)
|
|
119
|
+
db.executemany(
|
|
120
|
+
"INSERT OR IGNORE INTO notifications (notification_id, event_id, account_id, "
|
|
121
|
+
"user_id, state, severity, created_at, updated_at, sort_at) "
|
|
122
|
+
"VALUES (?, ?, ?, ?, 'unread', ?, ?, ?, ?)",
|
|
123
|
+
[
|
|
124
|
+
(
|
|
125
|
+
uuid.uuid4().hex,
|
|
126
|
+
event_id,
|
|
127
|
+
event.account_id,
|
|
128
|
+
user,
|
|
129
|
+
event.severity.value,
|
|
130
|
+
now.timestamp(),
|
|
131
|
+
now.timestamp(),
|
|
132
|
+
stored.last_occurred_at.timestamp(),
|
|
133
|
+
)
|
|
134
|
+
for user in users
|
|
135
|
+
],
|
|
136
|
+
)
|
|
137
|
+
channel = organization.types[event.type]
|
|
138
|
+
deliveries: list[tuple[str, str, str]] = [] # channel, destination, provider
|
|
139
|
+
# An aggregated event is delivered externally through its organization digest.
|
|
140
|
+
external = event.metadata.get("aggregated") is not True
|
|
141
|
+
if external and channel.email and self._settings.email_available:
|
|
142
|
+
deliveries += [
|
|
143
|
+
(NotificationChannel.EMAIL.value, address, "email")
|
|
144
|
+
for address in organization.email_recipients
|
|
145
|
+
]
|
|
146
|
+
if external and channel.webhook and self._settings.webhook_available:
|
|
147
|
+
deliveries += [
|
|
148
|
+
(NotificationChannel.WEBHOOK.value, str(endpoint["endpoint_id"]), "webhook")
|
|
149
|
+
for endpoint in db.execute(
|
|
150
|
+
"SELECT endpoint_id FROM notification_webhooks WHERE account_id = ? "
|
|
151
|
+
"AND removed_at IS NULL ORDER BY created_at",
|
|
152
|
+
(event.account_id,),
|
|
153
|
+
).fetchall()
|
|
154
|
+
]
|
|
155
|
+
db.executemany(
|
|
156
|
+
"INSERT OR IGNORE INTO notification_deliveries (delivery_id, event_id, account_id, "
|
|
157
|
+
"channel, destination, idempotency_key, status, attempt_count, provider, "
|
|
158
|
+
"created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?)",
|
|
159
|
+
[
|
|
160
|
+
(
|
|
161
|
+
uuid.uuid4().hex,
|
|
162
|
+
event_id,
|
|
163
|
+
event.account_id,
|
|
164
|
+
channel_name,
|
|
165
|
+
destination,
|
|
166
|
+
delivery_idempotency_key(event_id, channel_name, destination),
|
|
167
|
+
DeliveryStatus.PENDING.value,
|
|
168
|
+
provider,
|
|
169
|
+
now.timestamp(),
|
|
170
|
+
now.timestamp(),
|
|
171
|
+
)
|
|
172
|
+
for channel_name, destination, provider in deliveries
|
|
173
|
+
],
|
|
174
|
+
)
|
|
175
|
+
audit_event = self._store.insert_audit_event(
|
|
176
|
+
db,
|
|
177
|
+
self._audit.build(
|
|
178
|
+
AuditEventType.NOTIFICATION_CREATED,
|
|
179
|
+
account_id=event.account_id,
|
|
180
|
+
installation_id=event.installation_id,
|
|
181
|
+
repository_id=event.repository_id,
|
|
182
|
+
notification_type=event.type.value,
|
|
183
|
+
notification_event=event_id,
|
|
184
|
+
severity=event.severity.value,
|
|
185
|
+
recipients=len(users),
|
|
186
|
+
email_deliveries=sum(1 for d in deliveries if d[0] == "email"),
|
|
187
|
+
webhook_deliveries=sum(1 for d in deliveries if d[0] == "webhook"),
|
|
188
|
+
source_request=stored.request_id,
|
|
189
|
+
source_delivery=stored.delivery_id,
|
|
190
|
+
source_job=stored.job_id,
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
self._audit.log_stored(audit_event)
|
|
194
|
+
self._metrics.increment(NOTIFICATIONS_CREATED, type=event.type.value)
|
|
195
|
+
log.info(
|
|
196
|
+
"notification_dispatched",
|
|
197
|
+
type=event.type.value,
|
|
198
|
+
recipients=len(users),
|
|
199
|
+
deliveries=len(deliveries),
|
|
200
|
+
)
|
|
201
|
+
return True
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
"""Notification vocabulary: types, channels, states and the normalised event.
|
|
2
|
+
|
|
3
|
+
A notification never makes a security decision. It is produced *after* a
|
|
4
|
+
domain service has decided and stored something - a scan blocked a commit, an
|
|
5
|
+
administrator changed or rolled back policy, a GitHub installation lost access
|
|
6
|
+
- and it is written into the notification outbox in the same database
|
|
7
|
+
transaction as that change (:mod:`commitguard.notifications.outbox`).
|
|
8
|
+
|
|
9
|
+
Types
|
|
10
|
+
=====
|
|
11
|
+
|
|
12
|
+
============================= =========== =============================== =========
|
|
13
|
+
Type Category In-app recipients (permission) Mandatory
|
|
14
|
+
============================= =========== =============================== =========
|
|
15
|
+
``critical_violation`` violations ``violations:read`` in-app
|
|
16
|
+
``high_violation`` violations ``violations:read``
|
|
17
|
+
``policy_changed`` policy ``audit:read`` in-app
|
|
18
|
+
``policy_rolled_back`` policy ``audit:read`` in-app
|
|
19
|
+
``installation_disconnected`` github ``github:manage`` in-app
|
|
20
|
+
``installation_reconnected`` github ``github:manage``
|
|
21
|
+
``merge_queue_failure`` scans ``scans:read``
|
|
22
|
+
``check_rerun_failed`` scans ``scans:read``
|
|
23
|
+
``violation_digest`` violations ``violations:read``
|
|
24
|
+
``policy_approval_requested`` policy ``policies:approve``
|
|
25
|
+
``policy_emergency_published`` policy ``audit:read`` in-app
|
|
26
|
+
``policy_rollout_failed`` policy ``policies:publish`` in-app
|
|
27
|
+
``policy_propagation_failed`` policy ``policies:publish`` in-app
|
|
28
|
+
``exception_requested`` policy ``exceptions:approve``
|
|
29
|
+
``exception_approved`` policy ``exceptions:read``
|
|
30
|
+
``exception_expiring`` policy ``exceptions:read``
|
|
31
|
+
``exception_ended`` policy ``exceptions:read``
|
|
32
|
+
``repository_unprotected`` github ``repositories:manage`` in-app
|
|
33
|
+
``organization_settings_changed`` policy ``audit:read``
|
|
34
|
+
============================= =========== =============================== =========
|
|
35
|
+
|
|
36
|
+
``violation_digest`` is the organization-level aggregation of violation
|
|
37
|
+
alerts: when an organization enables it, one notification per rule and hour
|
|
38
|
+
("blocked commits in 14 repositories") replaces the per-pull-request e-mails and
|
|
39
|
+
webhooks; the dashboard keeps the per-repository detail.
|
|
40
|
+
|
|
41
|
+
"Mandatory" in-app notifications cannot be muted by a user or turned off by an
|
|
42
|
+
organization: they cover events after which enforcement may no longer be what
|
|
43
|
+
people believe it is. E-mail and webhook delivery is configurable per type by
|
|
44
|
+
administrators (``notifications:manage``).
|
|
45
|
+
|
|
46
|
+
Severity reuses the core :class:`~commitguard.core.result.Severity` model.
|
|
47
|
+
Violation notifications take the finding's severity; the bundled detectors
|
|
48
|
+
classify AI attribution as ``high``, so ``critical_violation`` is produced only
|
|
49
|
+
by rules that report ``critical``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
import re
|
|
53
|
+
import uuid
|
|
54
|
+
from dataclasses import dataclass
|
|
55
|
+
from datetime import datetime
|
|
56
|
+
from enum import StrEnum
|
|
57
|
+
from typing import Literal
|
|
58
|
+
|
|
59
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
60
|
+
|
|
61
|
+
from commitguard.controlplane.access import Permission
|
|
62
|
+
from commitguard.core.result import Severity
|
|
63
|
+
from commitguard.security.sanitization import sanitize_for_terminal
|
|
64
|
+
from commitguard.security.secrets import redact
|
|
65
|
+
|
|
66
|
+
MAX_TITLE_CHARS = 200
|
|
67
|
+
MAX_BODY_CHARS = 1000
|
|
68
|
+
MAX_METADATA_ITEMS = 24
|
|
69
|
+
MAX_METADATA_STRING = 256
|
|
70
|
+
_KEY_RE = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z")
|
|
71
|
+
|
|
72
|
+
type MetadataValue = str | int | bool | None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def clean(value: str, limit: int) -> str:
|
|
76
|
+
"""Untrusted text as stored in a notification: secrets redacted, controls visible."""
|
|
77
|
+
return sanitize_for_terminal(redact(value), max_length=limit)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class NotificationType(StrEnum):
|
|
81
|
+
CRITICAL_VIOLATION = "critical_violation"
|
|
82
|
+
HIGH_VIOLATION = "high_violation"
|
|
83
|
+
POLICY_CHANGED = "policy_changed"
|
|
84
|
+
POLICY_ROLLED_BACK = "policy_rolled_back"
|
|
85
|
+
INSTALLATION_DISCONNECTED = "installation_disconnected"
|
|
86
|
+
INSTALLATION_RECONNECTED = "installation_reconnected"
|
|
87
|
+
MERGE_QUEUE_FAILURE = "merge_queue_failure"
|
|
88
|
+
CHECK_RERUN_FAILED = "check_rerun_failed"
|
|
89
|
+
# Phase 8: organization governance.
|
|
90
|
+
VIOLATION_DIGEST = "violation_digest"
|
|
91
|
+
POLICY_APPROVAL_REQUESTED = "policy_approval_requested"
|
|
92
|
+
POLICY_EMERGENCY_PUBLISHED = "policy_emergency_published"
|
|
93
|
+
POLICY_ROLLOUT_FAILED = "policy_rollout_failed"
|
|
94
|
+
POLICY_PROPAGATION_FAILED = "policy_propagation_failed"
|
|
95
|
+
EXCEPTION_REQUESTED = "exception_requested"
|
|
96
|
+
EXCEPTION_APPROVED = "exception_approved"
|
|
97
|
+
EXCEPTION_EXPIRING = "exception_expiring"
|
|
98
|
+
EXCEPTION_ENDED = "exception_ended"
|
|
99
|
+
REPOSITORY_UNPROTECTED = "repository_unprotected"
|
|
100
|
+
ORGANIZATION_SETTINGS_CHANGED = "organization_settings_changed"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class NotificationCategory(StrEnum):
|
|
104
|
+
VIOLATIONS = "violations"
|
|
105
|
+
POLICY = "policy"
|
|
106
|
+
GITHUB = "github"
|
|
107
|
+
SCANS = "scans"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class NotificationChannel(StrEnum):
|
|
111
|
+
IN_APP = "in_app"
|
|
112
|
+
EMAIL = "email"
|
|
113
|
+
WEBHOOK = "webhook"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class NotificationState(StrEnum):
|
|
117
|
+
UNREAD = "unread"
|
|
118
|
+
READ = "read"
|
|
119
|
+
ARCHIVED = "archived"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DeliveryStatus(StrEnum):
|
|
123
|
+
PENDING = "pending"
|
|
124
|
+
SENT = "sent"
|
|
125
|
+
FAILED = "failed"
|
|
126
|
+
CANCELLED = "cancelled"
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
ResourceType = Literal[
|
|
130
|
+
"violation",
|
|
131
|
+
"scan",
|
|
132
|
+
"policy",
|
|
133
|
+
"installation",
|
|
134
|
+
"repository",
|
|
135
|
+
"exception",
|
|
136
|
+
"draft",
|
|
137
|
+
"rollout",
|
|
138
|
+
"organization",
|
|
139
|
+
]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@dataclass(frozen=True, slots=True)
|
|
143
|
+
class TypeDefinition:
|
|
144
|
+
type: NotificationType
|
|
145
|
+
label: str
|
|
146
|
+
description: str
|
|
147
|
+
category: NotificationCategory
|
|
148
|
+
permission: Permission # who receives the in-app notification
|
|
149
|
+
repository_scoped: bool # only users who can see the repository on GitHub see it
|
|
150
|
+
mandatory_in_app: bool
|
|
151
|
+
default_email: bool
|
|
152
|
+
default_webhook: bool
|
|
153
|
+
default_in_app: bool = True
|
|
154
|
+
#: repeated events with the same key inside this window update one notification
|
|
155
|
+
coalesce_seconds: int | None = None
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
DEFINITIONS: dict[NotificationType, TypeDefinition] = {
|
|
159
|
+
d.type: d
|
|
160
|
+
for d in (
|
|
161
|
+
TypeDefinition(
|
|
162
|
+
NotificationType.CRITICAL_VIOLATION,
|
|
163
|
+
"Critical violations",
|
|
164
|
+
"A scan blocked commits for a finding classified critical.",
|
|
165
|
+
NotificationCategory.VIOLATIONS,
|
|
166
|
+
Permission.VIOLATIONS_READ,
|
|
167
|
+
repository_scoped=True,
|
|
168
|
+
mandatory_in_app=True,
|
|
169
|
+
default_email=True,
|
|
170
|
+
default_webhook=True,
|
|
171
|
+
coalesce_seconds=3600,
|
|
172
|
+
),
|
|
173
|
+
TypeDefinition(
|
|
174
|
+
NotificationType.HIGH_VIOLATION,
|
|
175
|
+
"High-severity violations",
|
|
176
|
+
"A scan blocked commits for a finding classified high (for example AI attribution).",
|
|
177
|
+
NotificationCategory.VIOLATIONS,
|
|
178
|
+
Permission.VIOLATIONS_READ,
|
|
179
|
+
repository_scoped=True,
|
|
180
|
+
mandatory_in_app=False,
|
|
181
|
+
default_email=False,
|
|
182
|
+
default_webhook=False,
|
|
183
|
+
coalesce_seconds=3600,
|
|
184
|
+
),
|
|
185
|
+
TypeDefinition(
|
|
186
|
+
NotificationType.POLICY_CHANGED,
|
|
187
|
+
"Policy changes",
|
|
188
|
+
"An administrator published a new organization policy version.",
|
|
189
|
+
NotificationCategory.POLICY,
|
|
190
|
+
Permission.AUDIT_READ,
|
|
191
|
+
repository_scoped=False,
|
|
192
|
+
mandatory_in_app=True,
|
|
193
|
+
default_email=True,
|
|
194
|
+
default_webhook=True,
|
|
195
|
+
),
|
|
196
|
+
TypeDefinition(
|
|
197
|
+
NotificationType.POLICY_ROLLED_BACK,
|
|
198
|
+
"Policy rollbacks",
|
|
199
|
+
"An administrator restored an earlier organization policy version.",
|
|
200
|
+
NotificationCategory.POLICY,
|
|
201
|
+
Permission.AUDIT_READ,
|
|
202
|
+
repository_scoped=False,
|
|
203
|
+
mandatory_in_app=True,
|
|
204
|
+
default_email=True,
|
|
205
|
+
default_webhook=True,
|
|
206
|
+
),
|
|
207
|
+
TypeDefinition(
|
|
208
|
+
NotificationType.INSTALLATION_DISCONNECTED,
|
|
209
|
+
"Installation disconnects",
|
|
210
|
+
"The GitHub App was uninstalled or suspended: GitHub enforcement is at risk.",
|
|
211
|
+
NotificationCategory.GITHUB,
|
|
212
|
+
Permission.GITHUB_MANAGE,
|
|
213
|
+
repository_scoped=False,
|
|
214
|
+
mandatory_in_app=True,
|
|
215
|
+
default_email=True,
|
|
216
|
+
default_webhook=True,
|
|
217
|
+
coalesce_seconds=3600,
|
|
218
|
+
),
|
|
219
|
+
TypeDefinition(
|
|
220
|
+
NotificationType.INSTALLATION_RECONNECTED,
|
|
221
|
+
"Installation reconnects",
|
|
222
|
+
"A GitHub App installation became available again.",
|
|
223
|
+
NotificationCategory.GITHUB,
|
|
224
|
+
Permission.GITHUB_MANAGE,
|
|
225
|
+
repository_scoped=False,
|
|
226
|
+
mandatory_in_app=False,
|
|
227
|
+
default_email=True,
|
|
228
|
+
default_webhook=True,
|
|
229
|
+
coalesce_seconds=3600,
|
|
230
|
+
),
|
|
231
|
+
TypeDefinition(
|
|
232
|
+
NotificationType.MERGE_QUEUE_FAILURE,
|
|
233
|
+
"Merge queue failures",
|
|
234
|
+
"A merge group was blocked, or could not be validated.",
|
|
235
|
+
NotificationCategory.SCANS,
|
|
236
|
+
Permission.SCANS_READ,
|
|
237
|
+
repository_scoped=True,
|
|
238
|
+
mandatory_in_app=False,
|
|
239
|
+
default_email=False,
|
|
240
|
+
default_webhook=True,
|
|
241
|
+
),
|
|
242
|
+
TypeDefinition(
|
|
243
|
+
NotificationType.CHECK_RERUN_FAILED,
|
|
244
|
+
"Check re-run failures",
|
|
245
|
+
"A re-run of the CommitGuard check could not be completed.",
|
|
246
|
+
NotificationCategory.SCANS,
|
|
247
|
+
Permission.SCANS_READ,
|
|
248
|
+
repository_scoped=True,
|
|
249
|
+
mandatory_in_app=False,
|
|
250
|
+
default_email=False,
|
|
251
|
+
default_webhook=False,
|
|
252
|
+
),
|
|
253
|
+
TypeDefinition(
|
|
254
|
+
NotificationType.VIOLATION_DIGEST,
|
|
255
|
+
"Violation digests",
|
|
256
|
+
"Blocked violations across repositories, aggregated per rule and hour.",
|
|
257
|
+
NotificationCategory.VIOLATIONS,
|
|
258
|
+
Permission.VIOLATIONS_READ,
|
|
259
|
+
repository_scoped=False,
|
|
260
|
+
mandatory_in_app=False,
|
|
261
|
+
default_email=True,
|
|
262
|
+
default_webhook=True,
|
|
263
|
+
coalesce_seconds=3600,
|
|
264
|
+
),
|
|
265
|
+
TypeDefinition(
|
|
266
|
+
NotificationType.POLICY_APPROVAL_REQUESTED,
|
|
267
|
+
"Policy approval requests",
|
|
268
|
+
"A policy change is waiting for approval.",
|
|
269
|
+
NotificationCategory.POLICY,
|
|
270
|
+
Permission.POLICIES_APPROVE,
|
|
271
|
+
repository_scoped=False,
|
|
272
|
+
mandatory_in_app=False,
|
|
273
|
+
default_email=True,
|
|
274
|
+
default_webhook=False,
|
|
275
|
+
),
|
|
276
|
+
TypeDefinition(
|
|
277
|
+
NotificationType.POLICY_EMERGENCY_PUBLISHED,
|
|
278
|
+
"Emergency policy publications",
|
|
279
|
+
"A policy change was published without the approval workflow.",
|
|
280
|
+
NotificationCategory.POLICY,
|
|
281
|
+
Permission.AUDIT_READ,
|
|
282
|
+
repository_scoped=False,
|
|
283
|
+
mandatory_in_app=True,
|
|
284
|
+
default_email=True,
|
|
285
|
+
default_webhook=True,
|
|
286
|
+
),
|
|
287
|
+
TypeDefinition(
|
|
288
|
+
NotificationType.POLICY_ROLLOUT_FAILED,
|
|
289
|
+
"Policy rollout failures",
|
|
290
|
+
"A staged policy rollout was paused or rolled back by its safety thresholds.",
|
|
291
|
+
NotificationCategory.POLICY,
|
|
292
|
+
Permission.POLICIES_PUBLISH,
|
|
293
|
+
repository_scoped=False,
|
|
294
|
+
mandatory_in_app=True,
|
|
295
|
+
default_email=True,
|
|
296
|
+
default_webhook=True,
|
|
297
|
+
),
|
|
298
|
+
TypeDefinition(
|
|
299
|
+
NotificationType.POLICY_PROPAGATION_FAILED,
|
|
300
|
+
"Policy propagation failures",
|
|
301
|
+
"The effective policy of one or more repositories could not be updated.",
|
|
302
|
+
NotificationCategory.POLICY,
|
|
303
|
+
Permission.POLICIES_PUBLISH,
|
|
304
|
+
repository_scoped=False,
|
|
305
|
+
mandatory_in_app=True,
|
|
306
|
+
default_email=True,
|
|
307
|
+
default_webhook=True,
|
|
308
|
+
coalesce_seconds=3600,
|
|
309
|
+
),
|
|
310
|
+
TypeDefinition(
|
|
311
|
+
NotificationType.EXCEPTION_REQUESTED,
|
|
312
|
+
"Exception requests",
|
|
313
|
+
"A policy exception for a high or critical rule is waiting for approval.",
|
|
314
|
+
NotificationCategory.POLICY,
|
|
315
|
+
Permission.EXCEPTIONS_APPROVE,
|
|
316
|
+
repository_scoped=False,
|
|
317
|
+
mandatory_in_app=False,
|
|
318
|
+
default_email=True,
|
|
319
|
+
default_webhook=False,
|
|
320
|
+
),
|
|
321
|
+
TypeDefinition(
|
|
322
|
+
NotificationType.EXCEPTION_APPROVED,
|
|
323
|
+
"Exception approvals",
|
|
324
|
+
"A policy exception became active and lowers enforcement until it expires.",
|
|
325
|
+
NotificationCategory.POLICY,
|
|
326
|
+
Permission.EXCEPTIONS_READ,
|
|
327
|
+
repository_scoped=False,
|
|
328
|
+
mandatory_in_app=False,
|
|
329
|
+
default_email=True,
|
|
330
|
+
default_webhook=True,
|
|
331
|
+
),
|
|
332
|
+
TypeDefinition(
|
|
333
|
+
NotificationType.EXCEPTION_EXPIRING,
|
|
334
|
+
"Exceptions expiring soon",
|
|
335
|
+
"An active policy exception expires soon; the policy applies again afterwards.",
|
|
336
|
+
NotificationCategory.POLICY,
|
|
337
|
+
Permission.EXCEPTIONS_READ,
|
|
338
|
+
repository_scoped=False,
|
|
339
|
+
mandatory_in_app=False,
|
|
340
|
+
default_email=False,
|
|
341
|
+
default_webhook=False,
|
|
342
|
+
),
|
|
343
|
+
TypeDefinition(
|
|
344
|
+
NotificationType.EXCEPTION_ENDED,
|
|
345
|
+
"Exceptions ended",
|
|
346
|
+
"A policy exception expired or was revoked: the policy applies again.",
|
|
347
|
+
NotificationCategory.POLICY,
|
|
348
|
+
Permission.EXCEPTIONS_READ,
|
|
349
|
+
repository_scoped=False,
|
|
350
|
+
mandatory_in_app=False,
|
|
351
|
+
default_email=False,
|
|
352
|
+
default_webhook=True,
|
|
353
|
+
),
|
|
354
|
+
TypeDefinition(
|
|
355
|
+
NotificationType.REPOSITORY_UNPROTECTED,
|
|
356
|
+
"Repositories losing protection",
|
|
357
|
+
"A repository that was protected is no longer protected by GitHub.",
|
|
358
|
+
NotificationCategory.GITHUB,
|
|
359
|
+
Permission.REPOSITORIES_MANAGE,
|
|
360
|
+
repository_scoped=True,
|
|
361
|
+
mandatory_in_app=True,
|
|
362
|
+
default_email=True,
|
|
363
|
+
default_webhook=True,
|
|
364
|
+
coalesce_seconds=3600,
|
|
365
|
+
),
|
|
366
|
+
TypeDefinition(
|
|
367
|
+
NotificationType.ORGANIZATION_SETTINGS_CHANGED,
|
|
368
|
+
"Security settings changes",
|
|
369
|
+
"An administrator changed organization security settings.",
|
|
370
|
+
NotificationCategory.POLICY,
|
|
371
|
+
Permission.AUDIT_READ,
|
|
372
|
+
repository_scoped=False,
|
|
373
|
+
mandatory_in_app=False,
|
|
374
|
+
default_email=True,
|
|
375
|
+
default_webhook=True,
|
|
376
|
+
),
|
|
377
|
+
)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
class NotificationEvent(BaseModel):
|
|
382
|
+
"""A normalised notification event, as written to the outbox."""
|
|
383
|
+
|
|
384
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
385
|
+
|
|
386
|
+
event_id: str = Field(default_factory=lambda: uuid.uuid4().hex)
|
|
387
|
+
type: NotificationType
|
|
388
|
+
account_id: int
|
|
389
|
+
severity: Severity
|
|
390
|
+
installation_id: int | None = None
|
|
391
|
+
repository_id: int | None = None
|
|
392
|
+
resource_type: ResourceType
|
|
393
|
+
resource_id: str = Field(min_length=1, max_length=64)
|
|
394
|
+
#: Domain identity of the underlying occurrence, before the coalescing window.
|
|
395
|
+
dedup_key: str = Field(min_length=1, max_length=512)
|
|
396
|
+
title: str
|
|
397
|
+
body: str
|
|
398
|
+
metadata: dict[str, MetadataValue] = {}
|
|
399
|
+
|
|
400
|
+
@field_validator("title")
|
|
401
|
+
@classmethod
|
|
402
|
+
def _title(cls, value: str) -> str:
|
|
403
|
+
return clean(value, MAX_TITLE_CHARS)
|
|
404
|
+
|
|
405
|
+
@field_validator("body")
|
|
406
|
+
@classmethod
|
|
407
|
+
def _body(cls, value: str) -> str:
|
|
408
|
+
return clean(value, MAX_BODY_CHARS)
|
|
409
|
+
|
|
410
|
+
@field_validator("metadata")
|
|
411
|
+
@classmethod
|
|
412
|
+
def _metadata(cls, value: dict[str, MetadataValue]) -> dict[str, MetadataValue]:
|
|
413
|
+
if len(value) > MAX_METADATA_ITEMS:
|
|
414
|
+
raise ValueError(f"notification metadata has more than {MAX_METADATA_ITEMS} items")
|
|
415
|
+
result: dict[str, MetadataValue] = {}
|
|
416
|
+
for key, item in value.items():
|
|
417
|
+
if not _KEY_RE.match(key):
|
|
418
|
+
raise ValueError(f"invalid notification metadata key {key!r}")
|
|
419
|
+
result[key] = clean(item, MAX_METADATA_STRING) if isinstance(item, str) else item
|
|
420
|
+
return result
|
|
421
|
+
|
|
422
|
+
@property
|
|
423
|
+
def definition(self) -> TypeDefinition:
|
|
424
|
+
return DEFINITIONS[self.type]
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
class StoredNotificationEvent(BaseModel):
|
|
428
|
+
"""A notification event as read back from the outbox."""
|
|
429
|
+
|
|
430
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
431
|
+
|
|
432
|
+
event: NotificationEvent
|
|
433
|
+
occurrences: int
|
|
434
|
+
created_at: datetime
|
|
435
|
+
last_occurred_at: datetime
|
|
436
|
+
dispatched_at: datetime | None
|
|
437
|
+
request_id: str | None
|
|
438
|
+
delivery_id: str | None
|
|
439
|
+
job_id: str | None
|