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 @@
|
|
|
1
|
+
"""Notification channels: in-app (database), e-mail (SMTP) and signed webhooks."""
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""What every external delivery channel returns and raises."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class DeliveryError(Exception):
|
|
7
|
+
"""A delivery attempt failed.
|
|
8
|
+
|
|
9
|
+
``code`` is a short, non-sensitive label stored with the delivery record
|
|
10
|
+
(never a provider response body). ``permanent`` failures are not retried.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
def __init__(self, code: str, *, permanent: bool = False) -> None:
|
|
14
|
+
super().__init__(code)
|
|
15
|
+
self.code = code[:64]
|
|
16
|
+
self.permanent = permanent
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True, slots=True)
|
|
20
|
+
class DeliveryReceipt:
|
|
21
|
+
provider: str
|
|
22
|
+
provider_message_id: str | None
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""E-mail channel.
|
|
2
|
+
|
|
3
|
+
:class:`EmailProvider` is the interface the notification domain depends on;
|
|
4
|
+
:class:`SmtpEmailProvider` is the implementation shipped in this phase (any
|
|
5
|
+
SMTP relay, including those of SES, SendGrid or Resend). Provider credentials
|
|
6
|
+
come from the environment or a secret file (:mod:`commitguard.notifications.settings`),
|
|
7
|
+
never from repository or organization configuration.
|
|
8
|
+
|
|
9
|
+
Messages are **plain text** only: repository names, rule titles and other
|
|
10
|
+
repository-controlled values are never interpreted as HTML. Header values are
|
|
11
|
+
set through :class:`email.message.EmailMessage`, which rejects line breaks, so
|
|
12
|
+
untrusted text cannot inject headers. ``Message-ID`` is derived from the
|
|
13
|
+
delivery's idempotency key, so a retried message can be recognised as the same
|
|
14
|
+
message by the relay and by recipients' mail systems.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import smtplib
|
|
18
|
+
import ssl
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from email.message import EmailMessage
|
|
21
|
+
from email.utils import formatdate
|
|
22
|
+
from typing import Literal, Protocol
|
|
23
|
+
|
|
24
|
+
from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
|
|
25
|
+
from commitguard.security.secrets import Secret
|
|
26
|
+
|
|
27
|
+
SMTP_TIMEOUT_SECONDS = 15.0
|
|
28
|
+
|
|
29
|
+
type SmtpSecurity = Literal["starttls", "tls", "none"]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class OutgoingEmail:
|
|
34
|
+
to: str
|
|
35
|
+
subject: str
|
|
36
|
+
text: str
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def build_message(sender: str, email: OutgoingEmail, idempotency_key: str) -> EmailMessage:
|
|
40
|
+
message = EmailMessage()
|
|
41
|
+
message["From"] = sender
|
|
42
|
+
message["To"] = email.to
|
|
43
|
+
message["Subject"] = " ".join(email.subject.split())[:200]
|
|
44
|
+
message["Date"] = formatdate(usegmt=True)
|
|
45
|
+
domain = sender.rsplit("@", 1)[-1] if "@" in sender else "commitguard.invalid"
|
|
46
|
+
message["Message-ID"] = f"<{idempotency_key[:64]}@{domain}>"
|
|
47
|
+
message["Auto-Submitted"] = "auto-generated"
|
|
48
|
+
message.set_content(email.text, subtype="plain", charset="utf-8")
|
|
49
|
+
return message
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class EmailProvider(Protocol):
|
|
53
|
+
name: str
|
|
54
|
+
|
|
55
|
+
def send(self, email: OutgoingEmail, *, idempotency_key: str) -> DeliveryReceipt: ...
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class SmtpEmailProvider:
|
|
59
|
+
name = "smtp"
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
*,
|
|
64
|
+
host: str,
|
|
65
|
+
port: int,
|
|
66
|
+
sender: str,
|
|
67
|
+
security: SmtpSecurity = "starttls",
|
|
68
|
+
username: str | None = None,
|
|
69
|
+
password: Secret | None = None,
|
|
70
|
+
timeout: float = SMTP_TIMEOUT_SECONDS,
|
|
71
|
+
) -> None:
|
|
72
|
+
self._host = host
|
|
73
|
+
self._port = port
|
|
74
|
+
self._sender = sender
|
|
75
|
+
self._security = security
|
|
76
|
+
self._username = username
|
|
77
|
+
self._password = password
|
|
78
|
+
self._timeout = timeout
|
|
79
|
+
|
|
80
|
+
def send(self, email: OutgoingEmail, *, idempotency_key: str) -> DeliveryReceipt:
|
|
81
|
+
message = build_message(self._sender, email, idempotency_key)
|
|
82
|
+
context = ssl.create_default_context()
|
|
83
|
+
try:
|
|
84
|
+
client: smtplib.SMTP
|
|
85
|
+
if self._security == "tls":
|
|
86
|
+
client = smtplib.SMTP_SSL(
|
|
87
|
+
self._host, self._port, timeout=self._timeout, context=context
|
|
88
|
+
)
|
|
89
|
+
else:
|
|
90
|
+
client = smtplib.SMTP(self._host, self._port, timeout=self._timeout)
|
|
91
|
+
with client:
|
|
92
|
+
if self._security == "starttls":
|
|
93
|
+
client.starttls(context=context)
|
|
94
|
+
if self._username and self._password:
|
|
95
|
+
client.login(self._username, self._password.reveal())
|
|
96
|
+
refused = client.send_message(message)
|
|
97
|
+
except smtplib.SMTPRecipientsRefused:
|
|
98
|
+
raise DeliveryError("recipient_refused", permanent=True) from None
|
|
99
|
+
except smtplib.SMTPAuthenticationError:
|
|
100
|
+
raise DeliveryError("smtp_authentication_failed") from None
|
|
101
|
+
except smtplib.SMTPResponseException as exc:
|
|
102
|
+
permanent = 500 <= exc.smtp_code < 600
|
|
103
|
+
raise DeliveryError(f"smtp_{exc.smtp_code}", permanent=permanent) from None
|
|
104
|
+
except TimeoutError:
|
|
105
|
+
raise DeliveryError("smtp_timeout") from None
|
|
106
|
+
except (smtplib.SMTPException, ssl.SSLError, OSError) as exc:
|
|
107
|
+
raise DeliveryError(f"smtp_unavailable_{type(exc).__name__}"[:64]) from None
|
|
108
|
+
if refused:
|
|
109
|
+
raise DeliveryError("recipient_refused", permanent=True)
|
|
110
|
+
return DeliveryReceipt(self.name, str(message["Message-ID"]))
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""In-app channel: who receives a notification in their CommitGuard inbox.
|
|
2
|
+
|
|
3
|
+
Recipients are computed when the event is dispatched, from the organization's
|
|
4
|
+
current members:
|
|
5
|
+
|
|
6
|
+
* the member's role must grant the type's permission (for example
|
|
7
|
+
``github:manage`` for installation disconnects), and the owner of a personal
|
|
8
|
+
(user-account) installation is always included;
|
|
9
|
+
* the type must be enabled for the member (mandatory types always are).
|
|
10
|
+
|
|
11
|
+
Repository visibility on GitHub is per session (reported by GitHub at
|
|
12
|
+
sign-in), so it is applied when notifications are *read*: a notification about
|
|
13
|
+
a repository is only listed for a session that can see that repository, and the
|
|
14
|
+
role check is applied again at read time. A member who lost their role or
|
|
15
|
+
their GitHub access stops seeing notifications immediately.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import sqlite3
|
|
19
|
+
|
|
20
|
+
from commitguard.controlplane.access import Role
|
|
21
|
+
from commitguard.notifications.models import NotificationType, TypeDefinition
|
|
22
|
+
from commitguard.notifications.preferences import (
|
|
23
|
+
OrganizationSettings,
|
|
24
|
+
UserPreferences,
|
|
25
|
+
in_app_enabled,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def eligible_members(
|
|
30
|
+
db: sqlite3.Connection,
|
|
31
|
+
account_id: int,
|
|
32
|
+
definition: TypeDefinition,
|
|
33
|
+
) -> set[int]:
|
|
34
|
+
users = {
|
|
35
|
+
int(row["user_id"])
|
|
36
|
+
for row in db.execute(
|
|
37
|
+
"SELECT user_id, role FROM memberships WHERE account_id = ?", (int(account_id),)
|
|
38
|
+
).fetchall()
|
|
39
|
+
if row["role"] in {r.value for r in Role}
|
|
40
|
+
and definition.permission in Role(row["role"]).permissions
|
|
41
|
+
}
|
|
42
|
+
personal = db.execute(
|
|
43
|
+
"SELECT 1 FROM installations WHERE account_id = ? AND account_type = 'User' LIMIT 1",
|
|
44
|
+
(int(account_id),),
|
|
45
|
+
).fetchone()
|
|
46
|
+
if personal is not None:
|
|
47
|
+
users.add(int(account_id)) # a user account's GitHub user ID is its account ID
|
|
48
|
+
return users
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def recipients(
|
|
52
|
+
db: sqlite3.Connection,
|
|
53
|
+
account_id: int,
|
|
54
|
+
definition: TypeDefinition,
|
|
55
|
+
organization: OrganizationSettings,
|
|
56
|
+
) -> list[int]:
|
|
57
|
+
muted = {
|
|
58
|
+
int(row["user_id"])
|
|
59
|
+
for row in db.execute(
|
|
60
|
+
"SELECT user_id FROM notification_user_preferences WHERE account_id = ? "
|
|
61
|
+
"AND type = ? AND in_app = 0",
|
|
62
|
+
(int(account_id), definition.type.value),
|
|
63
|
+
).fetchall()
|
|
64
|
+
}
|
|
65
|
+
result = []
|
|
66
|
+
for user_id in sorted(eligible_members(db, account_id, definition)):
|
|
67
|
+
preferences = UserPreferences(
|
|
68
|
+
user_id,
|
|
69
|
+
account_id,
|
|
70
|
+
frozenset({definition.type}) if user_id in muted else frozenset[NotificationType](),
|
|
71
|
+
)
|
|
72
|
+
if in_app_enabled(definition, organization, preferences):
|
|
73
|
+
result.append(user_id)
|
|
74
|
+
return result
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Test mode: channels that record deliveries instead of sending them.
|
|
2
|
+
|
|
3
|
+
With ``COMMITGUARD_NOTIFICATIONS_MODE=test`` (and always when
|
|
4
|
+
``COMMITGUARD_ENV=test``) e-mail and webhook deliveries go through the full
|
|
5
|
+
pipeline - outbox, preferences, delivery records, retries, audit - but the
|
|
6
|
+
final hop is replaced by these in-memory sinks, so a test or staging
|
|
7
|
+
environment can never reach a real mailbox or endpoint.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import threading
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
|
|
15
|
+
from commitguard.notifications.channels.email import OutgoingEmail
|
|
16
|
+
from commitguard.notifications.channels.webhook import WebhookRequest
|
|
17
|
+
|
|
18
|
+
MAX_RECORDED = 1000
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class RecordedEmail:
|
|
23
|
+
email: OutgoingEmail
|
|
24
|
+
idempotency_key: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class RecordingEmailProvider:
|
|
28
|
+
name = "test-sink"
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._lock = threading.Lock()
|
|
32
|
+
self.sent: list[RecordedEmail] = []
|
|
33
|
+
self.failure: DeliveryError | None = None # tests simulate an outage
|
|
34
|
+
|
|
35
|
+
def send(self, email: OutgoingEmail, *, idempotency_key: str) -> DeliveryReceipt:
|
|
36
|
+
with self._lock:
|
|
37
|
+
if self.failure is not None:
|
|
38
|
+
raise self.failure
|
|
39
|
+
self.sent.append(RecordedEmail(email, idempotency_key))
|
|
40
|
+
del self.sent[:-MAX_RECORDED]
|
|
41
|
+
return DeliveryReceipt(self.name, f"test-{idempotency_key[:24]}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RecordingWebhookTransport:
|
|
45
|
+
def __init__(self) -> None:
|
|
46
|
+
self._lock = threading.Lock()
|
|
47
|
+
self.requests: list[WebhookRequest] = []
|
|
48
|
+
self.status = 200
|
|
49
|
+
|
|
50
|
+
def post(self, request: WebhookRequest, *, timeout: float) -> int:
|
|
51
|
+
with self._lock:
|
|
52
|
+
self.requests.append(request)
|
|
53
|
+
del self.requests[:-MAX_RECORDED]
|
|
54
|
+
return self.status
|
|
55
|
+
|
|
56
|
+
def payloads(self) -> list[dict[str, object]]:
|
|
57
|
+
with self._lock:
|
|
58
|
+
return [json.loads(r.body) for r in self.requests]
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
"""Signed outbound webhooks.
|
|
2
|
+
|
|
3
|
+
Request::
|
|
4
|
+
|
|
5
|
+
POST <endpoint URL> (HTTPS; redirects are not followed)
|
|
6
|
+
Content-Type: application/json
|
|
7
|
+
User-Agent: CommitGuard-Notifications
|
|
8
|
+
X-CommitGuard-Event: policy_rolled_back
|
|
9
|
+
X-CommitGuard-Delivery: <idempotency key> (same value on every retry)
|
|
10
|
+
X-CommitGuard-Timestamp: 1767225600 (Unix seconds)
|
|
11
|
+
X-CommitGuard-Signature: v1=<hex HMAC-SHA256(secret, "<timestamp>.<raw body>")>
|
|
12
|
+
|
|
13
|
+
Receivers verify the signature over the timestamp *and* the raw body with
|
|
14
|
+
:func:`verify_signature` (or the equivalent in their language), reject
|
|
15
|
+
timestamps outside a small window (default five minutes) to stop replays, and
|
|
16
|
+
use ``X-CommitGuard-Delivery`` to discard a retried delivery they already
|
|
17
|
+
processed.
|
|
18
|
+
|
|
19
|
+
Signing secrets: each endpoint has its own secret, derived from the
|
|
20
|
+
deployment's signing key (``COMMITGUARD_NOTIFICATION_SIGNING_KEY``) and the
|
|
21
|
+
endpoint ID with HMAC. Nothing secret is stored in the database; the secret is
|
|
22
|
+
shown once, when an administrator adds the endpoint. A tenant's secret reveals
|
|
23
|
+
nothing about another tenant's.
|
|
24
|
+
|
|
25
|
+
Network safety: the endpoint host is resolved at send time and the connection
|
|
26
|
+
is made to the resolved address itself (TLS still verifies the certificate for
|
|
27
|
+
the host name), so a DNS change between the address check and the connection
|
|
28
|
+
cannot redirect the request. Loopback, private, link-local and other
|
|
29
|
+
non-public addresses are refused in production.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import hashlib
|
|
33
|
+
import hmac
|
|
34
|
+
import http.client
|
|
35
|
+
import ipaddress
|
|
36
|
+
import socket
|
|
37
|
+
import ssl
|
|
38
|
+
from collections.abc import Callable, Mapping
|
|
39
|
+
from dataclasses import dataclass
|
|
40
|
+
from typing import Protocol
|
|
41
|
+
from urllib.parse import urlsplit
|
|
42
|
+
|
|
43
|
+
from commitguard.notifications.channels.base import DeliveryError, DeliveryReceipt
|
|
44
|
+
from commitguard.security.secrets import Secret
|
|
45
|
+
|
|
46
|
+
WEBHOOK_TIMEOUT_SECONDS = 10.0
|
|
47
|
+
SIGNATURE_TOLERANCE_SECONDS = 300
|
|
48
|
+
MAX_URL_CHARS = 2048
|
|
49
|
+
SIGNATURE_VERSION = "v1"
|
|
50
|
+
SECRET_PREFIX = "whsec_" # noqa: S105 - display prefix, not a secret
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def endpoint_secret(signing_key: Secret, endpoint_id: str) -> Secret:
|
|
54
|
+
digest = hmac.new(
|
|
55
|
+
signing_key.reveal().encode("utf-8"),
|
|
56
|
+
b"commitguard-webhook-endpoint\x1f" + endpoint_id.encode("ascii"),
|
|
57
|
+
hashlib.sha256,
|
|
58
|
+
).hexdigest()
|
|
59
|
+
return Secret(SECRET_PREFIX + digest)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def sign(secret: Secret, timestamp: int, body: bytes) -> str:
|
|
63
|
+
mac = hmac.new(secret.reveal().encode("utf-8"), f"{timestamp}.".encode() + body, hashlib.sha256)
|
|
64
|
+
return f"{SIGNATURE_VERSION}={mac.hexdigest()}"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def verify_signature(
|
|
68
|
+
secret: Secret,
|
|
69
|
+
*,
|
|
70
|
+
timestamp: str | None,
|
|
71
|
+
signature: str | None,
|
|
72
|
+
body: bytes,
|
|
73
|
+
now: float,
|
|
74
|
+
tolerance: int = SIGNATURE_TOLERANCE_SECONDS,
|
|
75
|
+
) -> bool:
|
|
76
|
+
"""Receiver-side check: authentic body, fresh timestamp (replay window)."""
|
|
77
|
+
if not timestamp or not signature or not timestamp.isascii() or not timestamp.isdigit():
|
|
78
|
+
return False
|
|
79
|
+
sent = int(timestamp)
|
|
80
|
+
if abs(now - sent) > tolerance:
|
|
81
|
+
return False
|
|
82
|
+
expected = sign(secret, sent, body)
|
|
83
|
+
return hmac.compare_digest(expected.encode("ascii"), signature.strip().encode("ascii"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class WebhookUrlError(ValueError):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def validate_webhook_url(url: object, *, allow_insecure_local: bool) -> str:
|
|
91
|
+
"""An endpoint URL administrators may register."""
|
|
92
|
+
if not isinstance(url, str) or not url or len(url) > MAX_URL_CHARS:
|
|
93
|
+
raise WebhookUrlError("the webhook URL must be an https:// URL")
|
|
94
|
+
if any(ord(c) < 0x21 or ord(c) == 0x7F for c in url):
|
|
95
|
+
raise WebhookUrlError("the webhook URL contains invalid characters")
|
|
96
|
+
parts = urlsplit(url)
|
|
97
|
+
host = parts.hostname
|
|
98
|
+
local = host in ("localhost", "127.0.0.1", "::1")
|
|
99
|
+
if parts.scheme != "https" and not (parts.scheme == "http" and local and allow_insecure_local):
|
|
100
|
+
raise WebhookUrlError("the webhook URL must use https")
|
|
101
|
+
if not host or parts.username or parts.password or parts.fragment:
|
|
102
|
+
raise WebhookUrlError("the webhook URL must not contain credentials or a fragment")
|
|
103
|
+
try:
|
|
104
|
+
_ = parts.port
|
|
105
|
+
except ValueError:
|
|
106
|
+
raise WebhookUrlError("the webhook URL has an invalid port") from None
|
|
107
|
+
if not allow_insecure_local:
|
|
108
|
+
try:
|
|
109
|
+
address = ipaddress.ip_address(host)
|
|
110
|
+
except ValueError:
|
|
111
|
+
address = None
|
|
112
|
+
if local or (address is not None and not address.is_global):
|
|
113
|
+
raise WebhookUrlError("the webhook URL must point to a public host")
|
|
114
|
+
return url
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True)
|
|
118
|
+
class WebhookRequest:
|
|
119
|
+
url: str
|
|
120
|
+
body: bytes
|
|
121
|
+
headers: Mapping[str, str]
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class WebhookTransport(Protocol):
|
|
125
|
+
def post(self, request: WebhookRequest, *, timeout: float) -> int:
|
|
126
|
+
"""Send the request and return the HTTP status (redirects are not followed)."""
|
|
127
|
+
...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
type Resolver = Callable[[str, int], list[str]]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _resolve(host: str, port: int) -> list[str]:
|
|
134
|
+
return sorted(
|
|
135
|
+
{str(info[4][0]) for info in socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)}
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class PinnedHttpsTransport:
|
|
140
|
+
"""HTTPS POST to the address that passed the network check (no DNS rebinding)."""
|
|
141
|
+
|
|
142
|
+
def __init__(self, *, allow_private: bool = False, resolver: Resolver = _resolve) -> None:
|
|
143
|
+
self._allow_private = allow_private
|
|
144
|
+
self._resolver = resolver
|
|
145
|
+
|
|
146
|
+
def post(self, request: WebhookRequest, *, timeout: float) -> int:
|
|
147
|
+
parts = urlsplit(request.url)
|
|
148
|
+
host = parts.hostname or ""
|
|
149
|
+
secure = parts.scheme == "https"
|
|
150
|
+
port = parts.port or (443 if secure else 80)
|
|
151
|
+
try:
|
|
152
|
+
addresses = self._resolver(host, port)
|
|
153
|
+
except OSError:
|
|
154
|
+
raise DeliveryError("webhook_dns_failed") from None
|
|
155
|
+
allowed = [a for a in addresses if self._allow_private or ipaddress.ip_address(a).is_global]
|
|
156
|
+
if not allowed:
|
|
157
|
+
raise DeliveryError("webhook_address_refused", permanent=True)
|
|
158
|
+
address = allowed[0]
|
|
159
|
+
path = (parts.path or "/") + (f"?{parts.query}" if parts.query else "")
|
|
160
|
+
connection: http.client.HTTPConnection
|
|
161
|
+
if secure:
|
|
162
|
+
connection = _PinnedHTTPSConnection(host, port, address, timeout)
|
|
163
|
+
else:
|
|
164
|
+
connection = http.client.HTTPConnection(address, port, timeout=timeout)
|
|
165
|
+
try:
|
|
166
|
+
connection.request("POST", path, body=request.body, headers=dict(request.headers))
|
|
167
|
+
response = connection.getresponse()
|
|
168
|
+
response.read(65536)
|
|
169
|
+
return int(response.status)
|
|
170
|
+
except TimeoutError:
|
|
171
|
+
raise DeliveryError("webhook_timeout") from None
|
|
172
|
+
except (ssl.SSLError, http.client.HTTPException, OSError) as exc:
|
|
173
|
+
raise DeliveryError(f"webhook_unreachable_{type(exc).__name__}"[:64]) from None
|
|
174
|
+
finally:
|
|
175
|
+
connection.close()
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class _PinnedHTTPSConnection(http.client.HTTPSConnection):
|
|
179
|
+
def __init__(self, host: str, port: int, address: str, timeout: float) -> None:
|
|
180
|
+
super().__init__(host, port, timeout=timeout, context=ssl.create_default_context())
|
|
181
|
+
self._address = address
|
|
182
|
+
|
|
183
|
+
def connect(self) -> None:
|
|
184
|
+
sock = socket.create_connection((self._address, self.port), self.timeout)
|
|
185
|
+
context = ssl.create_default_context()
|
|
186
|
+
self.sock = context.wrap_socket(sock, server_hostname=self.host)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
class WebhookProvider:
|
|
190
|
+
name = "webhook"
|
|
191
|
+
|
|
192
|
+
def __init__(
|
|
193
|
+
self,
|
|
194
|
+
signing_key: Secret,
|
|
195
|
+
transport: WebhookTransport,
|
|
196
|
+
*,
|
|
197
|
+
timeout: float = WEBHOOK_TIMEOUT_SECONDS,
|
|
198
|
+
) -> None:
|
|
199
|
+
self._signing_key = signing_key
|
|
200
|
+
self._transport = transport
|
|
201
|
+
self._timeout = timeout
|
|
202
|
+
|
|
203
|
+
def secret_for(self, endpoint_id: str) -> Secret:
|
|
204
|
+
return endpoint_secret(self._signing_key, endpoint_id)
|
|
205
|
+
|
|
206
|
+
def send(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
url: str,
|
|
210
|
+
endpoint_id: str,
|
|
211
|
+
event_type: str,
|
|
212
|
+
body: bytes,
|
|
213
|
+
idempotency_key: str,
|
|
214
|
+
timestamp: int,
|
|
215
|
+
) -> DeliveryReceipt:
|
|
216
|
+
headers = {
|
|
217
|
+
"Content-Type": "application/json",
|
|
218
|
+
"User-Agent": "CommitGuard-Notifications",
|
|
219
|
+
"X-CommitGuard-Event": event_type,
|
|
220
|
+
"X-CommitGuard-Delivery": idempotency_key,
|
|
221
|
+
"X-CommitGuard-Timestamp": str(timestamp),
|
|
222
|
+
"X-CommitGuard-Signature": sign(self.secret_for(endpoint_id), timestamp, body),
|
|
223
|
+
}
|
|
224
|
+
status = self._transport.post(
|
|
225
|
+
WebhookRequest(url=url, body=body, headers=headers), timeout=self._timeout
|
|
226
|
+
)
|
|
227
|
+
if 200 <= status < 300:
|
|
228
|
+
return DeliveryReceipt(self.name, idempotency_key)
|
|
229
|
+
if 300 <= status < 400:
|
|
230
|
+
raise DeliveryError("webhook_redirect_refused", permanent=True)
|
|
231
|
+
if status in (408, 425, 429) or status >= 500:
|
|
232
|
+
raise DeliveryError(f"webhook_http_{status}")
|
|
233
|
+
raise DeliveryError(f"webhook_http_{status}", permanent=True)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Notification deduplication.
|
|
2
|
+
|
|
3
|
+
Two mechanisms keep one underlying event from producing many notifications:
|
|
4
|
+
|
|
5
|
+
1. **Idempotent emission.** Every notification event has a *storage key*: its
|
|
6
|
+
domain identity (``dedup_key``) plus, for types with a coalescing window, the
|
|
7
|
+
window it falls in. The outbox has a unique constraint on
|
|
8
|
+
``(account_id, storage key)``. Emitting the same key again - a replayed
|
|
9
|
+
webhook, a retried job, twenty commits of one pull request detected in one
|
|
10
|
+
scan - updates the existing event (``occurrences`` + 1, newest text) instead
|
|
11
|
+
of inserting another one, and it creates no new e-mail or webhook deliveries.
|
|
12
|
+
2. **Idempotent delivery.** Each delivery has an idempotency key derived from
|
|
13
|
+
the event, the channel and the destination, also unique in the database, and
|
|
14
|
+
sent to the provider (``Message-ID`` for e-mail, ``X-CommitGuard-Delivery``
|
|
15
|
+
for webhooks) so a retry after an ambiguous failure can be recognised.
|
|
16
|
+
|
|
17
|
+
Domain keys, per type:
|
|
18
|
+
|
|
19
|
+
============================= ==================================================
|
|
20
|
+
``critical_violation``, installation, repository, pull request / branch /
|
|
21
|
+
``high_violation`` merge group, rule - one notification per rule per
|
|
22
|
+
change within an hour, however many commits
|
|
23
|
+
``policy_changed``, organization and the new version number
|
|
24
|
+
``policy_rolled_back``
|
|
25
|
+
``installation_disconnected`` installation (within an hour: flapping
|
|
26
|
+
``installation_reconnected`` suspend/unsuspend does not repeat the alert)
|
|
27
|
+
``merge_queue_failure`` repository and merge group commit
|
|
28
|
+
``check_rerun_failed`` the failed execution
|
|
29
|
+
============================= ==================================================
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from collections.abc import Iterable
|
|
33
|
+
from datetime import datetime
|
|
34
|
+
|
|
35
|
+
from commitguard.notifications.models import DEFINITIONS, NotificationType
|
|
36
|
+
from commitguard.security.hashing import fingerprint
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def domain_key(notification_type: NotificationType, *parts: str | int) -> str:
|
|
40
|
+
return ":".join([notification_type.value, *(str(p) for p in parts)])
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def storage_key(notification_type: NotificationType, dedup_key: str, occurred_at: datetime) -> str:
|
|
44
|
+
"""The outbox uniqueness key: the domain key, bucketed by the coalescing window."""
|
|
45
|
+
window = DEFINITIONS[notification_type].coalesce_seconds
|
|
46
|
+
if not window:
|
|
47
|
+
return dedup_key
|
|
48
|
+
bucket = int(occurred_at.timestamp() // window)
|
|
49
|
+
return f"{dedup_key}@{bucket}"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def delivery_idempotency_key(event_id: str, channel: str, destination: str) -> str:
|
|
53
|
+
return fingerprint(["notification-delivery", event_id, channel, destination])
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def unique_sorted(values: Iterable[str]) -> list[str]:
|
|
57
|
+
return sorted(set(values))
|